Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
# @suaveplan/logger - Foundation logging package — structured JSON output, level filtering, redaction, sampling, OTel correlation, and pluggable sinks.
## Summary
Enterprise-grade structured logger for Suaveplan. Provides a full pipeline: level filtering → OTel trace enrichment → sampling → redaction → sinks. Supports multiple output strategies (console, JSON/NDJSON, event bus, storage) with buffering and backpressure.
<!-- begin:auto-docs -->
## Documentation tree
- [abstract](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ - [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ - [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_ - [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_
## Features
- **Structured logging** — `LogRecord` with level, message, ISO timestamp, context bag, trace IDs
- **Level filtering** — `debug | info | warn | error` with hierarchical cutoff
- **OTel enrichment** — auto-injects `trace_id` / `span_id` from the active OpenTelemetry span
- **Sampling** — `HeadSampler` (deterministic FNV1a), `TailSampler` (buffered), `RuleSampler`, `SampleAll/None`
- **Redaction** — path-based and regex-pattern rules; respects `@suaveplan/error` `isSensitive()` markers
- **Pluggable sinks** — `ConsoleSink`, `JsonSink` (NDJSON), `BufferedSink` (async queue, backpressure), `MultiSink`
- **Concrete loggers** — `ConsoleLogger`, `EventLogger` (event-bus integration), `StorageLogger` (KV persistence)
- **Error bridge** — installs a global `SuaveplanError` interceptor so unhandled errors are logged automatically
- **Factory API** — `createLogger(config)` for declarative setup; `initLogger(config)` for one-call bootstrap
## Installation
```bash
bun add @suaveplan/logger
```
## Quick Start
```ts
import { initLogger } from "@suaveplan/logger/init/init";
import { ConsoleLogger } from "@suaveplan/logger/console/console-logger";
// Option 1 — factory bootstrap
const logger = initLogger({ type: "console", level: "info" });
logger.info("server started", { port: 3000 });
// Option 2 — direct construction
const log = new ConsoleLogger({ level: "debug", colors: true, timestamp: true });
log.debug("connecting", { host: "db.local" });
log.warn("slow query", { ms: 412 });
```
## Modules
| Module | Description |
|--------|-------------|
| [`src/abstract/abstract-logger`](src/abstract/abstract-logger.md) | Base pipeline class — all concrete loggers extend this |
| [`src/record/log-record`](src/record/log-record.md) | `LogRecord` type and Zod validation schemas |
| [`src/errors/logger-error`](src/errors/logger-error.md) | `LoggerError` + `LOGGER_CODES` error registry |
| [`src/otel/otel-context`](src/otel/otel-context.md) | OTel trace/span context extractor |
| [`src/sampling/sampling`](src/sampling/sampling.md) | All sampler implementations and `createSampler` factory |
| [`src/redaction/redaction`](src/redaction/redaction.md) | `RedactionEngine` and `applyRedaction` utility |
| [`src/sink/sink`](src/sink/sink.md) | Sink interface, `MultiSink`, `flushSink`, `closeSink` |
| [`src/sink/console-sink`](src/sink/console-sink.md) | ANSI-colored human-readable console output |
| [`src/sink/json-sink`](src/sink/json-sink.md) | NDJSON structured output to any writer |
| [`src/sink/buffered-sink`](src/sink/buffered-sink.md) | Async queue with configurable overflow policies |
| [`src/console/console-logger`](src/console/console-logger.md) | Ready-to-use console logger |
| [`src/event/event-logger`](src/event/event-logger.md) | Event-bus logger with per-level subscriptions |
| [`src/storage/storage-logger`](src/storage/storage-logger.md) | KV-backed persistent logger with batch flush |
| [`src/error-bridge/error-bridge`](src/error-bridge/error-bridge.md) | Global `SuaveplanError` interceptor |
| [`src/factory/factory`](src/factory/factory.md) | Declarative `createLogger` factory |
| [`src/init/init`](src/init/init.md) | One-call `initLogger` bootstrap with global accessor |
## Usage: Redaction
```ts
import { ConsoleLogger } from "@suaveplan/logger/console/console-logger";
const log = new ConsoleLogger({
level: "info",
redaction: {
rules: [
{ kind: "path", path: "user.password" },
{ kind: "pattern", pattern: /token/i },
],
},
});
log.info("login", { user: { password: "s3cr3t", email: "a@b.com" }, token: "xyz" });
// → password and token fields are replaced with "[REDACTED]"
```
## Usage: Sampling
```ts
import { createLogger } from "@suaveplan/logger/factory/factory";
const logger = createLogger({
type: "console",
level: "debug",
sampling: { kind: "head", options: { rate: 0.1 } }, // keep 10%
});
```
## Usage: Buffered JSON sink
```ts
import { BufferedSink } from "@suaveplan/logger/sink/buffered-sink";
import { JsonSink } from "@suaveplan/logger/sink/json-sink";
const inner = new JsonSink({ writer: (line) => fs.appendFileSync("app.log", line + "\n") });
const sink = new BufferedSink({ sink: inner, maxQueueSize: 1000, overflowPolicy: "drop" });
// ... on shutdown:
await sink.flush();
await sink.close();
```
## Usage: StorageLogger
```ts
import { StorageLogger } from "@suaveplan/logger/storage/storage-logger";
const logger = new StorageLogger(kvStorage, {
level: "info",
autoFlush: true,
flushInterval: 10_000,
maxBatchSize: 100,
});
logger.error("payment failed", { orderId: "ord_123" });
const recent = await logger.getLogs({ limit: 50 });
await logger.clearLogs(new Date(Date.now() - 7 * 86400_000)); // purge >7 days old
```
## Usage: Error bridge
```ts
import { installSuaveplanErrorInterceptor } from "@suaveplan/logger/error-bridge/error-bridge";
import { ConsoleLogger } from "@suaveplan/logger/console/console-logger";
const log = new ConsoleLogger({ level: "warn" });
installSuaveplanErrorInterceptor(log); // all future SuaveplanErrors are logged automatically
```
## API Reference
See each module's co-located `.md` file (linked in the table above) for full parameter and return-type documentation.
## License
Private — Suaveplan internal use only.

View File

@@ -0,0 +1,122 @@
{
"name": "@suaveplan/logger",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Foundation logging package — structured JSON output, level filtering, redaction, sampling, OTel correlation, and pluggable sinks.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./abstract": {
"types": "./dist/abstract/abstract-logger.d.ts",
"default": "./dist/abstract/abstract-logger.js"
},
"./console": {
"types": "./dist/console/console-logger.d.ts",
"default": "./dist/console/console-logger.js"
},
"./event": {
"types": "./dist/event/event-logger.d.ts",
"default": "./dist/event/event-logger.js"
},
"./storage": {
"types": "./dist/storage/storage-logger.d.ts",
"default": "./dist/storage/storage-logger.js"
},
"./factory": {
"types": "./dist/factory/factory.d.ts",
"default": "./dist/factory/factory.js"
},
"./errors": {
"types": "./dist/errors/logger-error.d.ts",
"default": "./dist/errors/logger-error.js"
},
"./record": {
"types": "./dist/record/log-record.d.ts",
"default": "./dist/record/log-record.js"
},
"./record.schemas": {
"types": "./dist/record/log-record.schemas.d.ts",
"default": "./dist/record/log-record.schemas.js"
},
"./redaction": {
"types": "./dist/redaction/redaction.d.ts",
"default": "./dist/redaction/redaction.js"
},
"./sampling": {
"types": "./dist/sampling/sampling.d.ts",
"default": "./dist/sampling/sampling.js"
},
"./sink": {
"types": "./dist/sink/sink.d.ts",
"default": "./dist/sink/sink.js"
},
"./otel": {
"types": "./dist/otel/otel-context.d.ts",
"default": "./dist/otel/otel-context.js"
},
"./error-bridge": {
"types": "./dist/error-bridge/error-bridge.d.ts",
"default": "./dist/error-bridge/error-bridge.js"
},
"./init": {
"types": "./dist/init/init.d.ts",
"default": "./dist/init/init.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/logger.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,109 @@
# abstract-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AbstractLogger` is the backbone of `@suaveplan/logger`. It owns the log pipeline: level filtering → metadata merging → redaction → OTel context attachment → structured record construction → sampling decision → sink dispatch → backward-compatible `writeLog()` hook. Every concrete logger (Console, Event, Storage) extends it and only overrides `writeLog()`. By centralising the pipeline here, every logger automatically inherits enterprise features (redaction, sampling, OTel correlation, metrics, clock injection) without duplicating logic.
The `Logger<TMetadata>` interface provides the stable consumer API. Consumers that only need to accept a logger in function signatures should type against `Logger<TMetadata>` rather than `AbstractLogger<TMetadata>`.
## Features
- Level filtering with runtime `setLevel()` / `getLevel()`
- `enable()` / `disable()` master switch
- Pluggable `sinks: Sink[]` — each `LogRecord` is dispatched to all sinks after pipeline processing
- Declarative redaction via `RedactionEngine` — applied to merged context before record construction
- Pluggable sampling via `Sampler` instance or `SamplingPolicy` discriminated union
- OTel trace/span ID auto-attachment from `@opentelemetry/api` active span (configurable, default on)
- Injectable `clock: Clock` from `@suaveplan/types` for deterministic timestamps in tests
- OTel metrics: `logger.records_total` counter, `logger.record_size_bytes` histogram, `logger.dropped_total` counter — all no-op when no OTel exporter is registered
- `getMetrics()` returns a snapshot including `droppedBySampling`, `droppedByBackpressure`, and `droppedBySinkError` counts. The three counters are mutually exclusive: a sink throw or rejection increments `droppedBySinkError` (and is reported to a fallback sink as `LoggerError(LOGGER.SINK)`), NOT `droppedByBackpressure`. Tests asserting on dropped-record counts must use the counter that matches the failure mode.
- Per-sink failure isolation: a thrown sink does NOT halt dispatch to remaining sinks. Failures are emitted to a configurable `fallbackSink` (default: bare `console.error`). This is what makes multi-sink logging safe to compose — one bad sink cannot take the others down.
## Usage: Basic Example
```ts
import { AbstractLogger } from "@suaveplan/logger/abstract";
import type { LogEntry } from "@suaveplan/logger/record";
class MyLogger extends AbstractLogger {
protected writeLog(entry: LogEntry): void {
// custom output logic
process.stdout.write(`[${entry.level}] ${entry.message}\n`);
}
}
const logger = new MyLogger({ level: "debug" });
logger.info("App started");
```
## Usage: Advanced Example
```ts
import { AbstractLogger } from "@suaveplan/logger/abstract";
import { JsonSink } from "@suaveplan/logger/sink";
import { HeadSampler } from "@suaveplan/logger/sampling";
class ProductionLogger extends AbstractLogger {
protected writeLog(_entry: LogEntry): void {
// no-op: all output goes through sinks
}
}
const logger = new ProductionLogger({
level: "info",
sinks: [new JsonSink()],
redaction: { rules: [{ kind: "path", path: "user.token" }] },
sampling: new HeadSampler({ rate: 0.1 }),
otel: true,
});
logger.info("Payment processed", { orderId: "o-1", user: { token: "abc" } });
// Sink sees: { ..., ctx: { orderId: "o-1", user: { token: "[REDACTED]" } } }
```
## API Reference
### `interface Logger<TMetadata>`
```ts
interface Logger<TMetadata> {
debug(message, metadata?, tags?): void;
info(message, metadata?, tags?): void;
warn(message, metadata?, tags?): void;
error(message, metadata?, tags?): void;
setLevel(level): void;
getLevel(): LogLevel;
enable(): void;
disable(): void;
isEnabled(): boolean;
getMetrics(): LoggerMetrics;
resetMetrics(): void;
}
```
### `abstract class AbstractLogger<TMetadata> implements Logger<TMetadata>`
**Constructor**: `new ConcreteLogger(config?: AbstractLoggerConfig<TMetadata>)`
| Config field | Type | Default | Description |
|-------------|------|---------|-------------|
| `level` | `LogLevel` | `"info"` | Minimum emit level |
| `enabled` | `boolean` | `true` | Master on/off |
| `metadata` | `TMetadata` | `{}` | Default context merged into every record |
| `tags` | `string[]` | `[]` | Global tags merged into every record |
| `sinks` | `Sink[]` | `[]` | Pluggable output targets |
| `redaction` | `RedactionOptions` | `{}` | Field-path and pattern redaction rules |
| `sampling` | `SamplingPolicy \| Sampler` | keep-all | Sampling policy |
| `clock` | `Clock` | `Date` | Injectable clock for timestamps |
| `otel` | `boolean` | `true` | Auto-attach OTel trace context |
#### `protected abstract writeLog(entry: LogEntry<TMetadata>): void`
Called after sink dispatch for backward-compatible concrete logger implementations.
## Implementation Notes
Tags are stored in the log record under `ctx.__tags` (a `string[]`) rather than as a top-level field because the `LogRecord` schema has only `ctx` for context data. Sinks can extract `__tags` for display; the `ConsoleSink` does this. The `writeLog()` hook receives a `LogEntry` (the legacy shape) so that subclasses written for the old API still function without changes.

View File

@@ -0,0 +1,201 @@
/**
* Spec-conformance tests for `openspec/specs/logging/spec.md`.
*
* Pins LG-12 (OTel instrumentation) by attaching a recording
* MeterProvider to `@opentelemetry/api` and asserting the three
* required instruments fire with the documented attributes; pins
* LG-20 (tree-shake) by checking `sideEffects: false` and that
* `package.json` declares the documented subpath exports.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
type Counter,
type Histogram,
type Meter,
metrics,
} from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { AbstractLogger } from "./abstract-logger.js";
interface CounterCall {
name: string;
value: number;
attrs?: Record<string, unknown>;
}
interface HistogramCall {
name: string;
value: number;
attrs?: Record<string, unknown>;
}
function recordingProvider(): {
counters: CounterCall[];
histograms: HistogramCall[];
install: () => void;
uninstall: () => void;
} {
const counters: CounterCall[] = [];
const histograms: HistogramCall[] = [];
const meter: Partial<Meter> = {
createCounter: (name: string): Counter =>
({
add: (v: number, a?: Record<string, unknown>) => {
counters.push({ name, value: v, attrs: a });
},
}) as unknown as Counter,
createHistogram: (name: string): Histogram =>
({
record: (v: number, a?: Record<string, unknown>) => {
histograms.push({ name, value: v, attrs: a });
},
}) as unknown as Histogram,
createUpDownCounter: () => ({ add: () => {} }) as unknown as Counter,
};
const provider = {
getMeter: (_name?: string) => meter as Meter,
};
return {
counters,
histograms,
install: () => {
metrics.setGlobalMeterProvider(
provider as unknown as Parameters<
typeof metrics.setGlobalMeterProvider
>[0]
);
},
uninstall: () => {
metrics.disable();
},
};
}
class TestLogger extends AbstractLogger {
public lines: string[] = [];
protected writeLog(): void {
this.lines.push("ok");
}
}
describe("AbstractLogger — OTel instrumentation [LG-12]", () => {
let p: ReturnType<typeof recordingProvider>;
beforeEach(() => {
p = recordingProvider();
p.install();
});
afterEach(() => {
p.uninstall();
});
it("emits logger.records_total + logger.record_size_bytes per log call", {
timeout: 5000,
}, () => {
const log = new TestLogger();
log.info("hello");
const recordsTotal = p.counters.filter(
(c) => c.name === "logger.records_total"
);
const recordSize = p.histograms.filter(
(h) => h.name === "logger.record_size_bytes"
);
expect(recordsTotal.length).toBeGreaterThan(0);
expect(recordsTotal[0]?.attrs).toMatchObject({ level: "info" });
expect(recordSize.length).toBeGreaterThan(0);
expect(recordSize[0]?.attrs).toMatchObject({ level: "info" });
});
it("emits logger.dropped_total with reason='sampling' on sample-drop", {
timeout: 5000,
}, () => {
const log = new TestLogger({
sampling: { decide: () => "drop" },
});
log.info("dropped");
const dropped = p.counters.filter(
(c) => c.name === "logger.dropped_total"
);
expect(dropped.length).toBeGreaterThan(0);
expect(dropped[0]?.attrs).toMatchObject({ reason: "sampling" });
});
it("emits logger.dropped_total with reason='sink_error' on async sink rejection", {
timeout: 5000,
}, async () => {
const log = new TestLogger({
sinks: [
{
write: () => Promise.reject(new Error("boom")),
},
],
fallbackSink: { write: () => {} },
});
log.info("oops");
// give the rejection handler a tick
await new Promise((r) => setTimeout(r, 10));
const dropped = p.counters.filter(
(c) =>
c.name === "logger.dropped_total" &&
c.attrs?.reason === "sink_error"
);
expect(dropped.length).toBeGreaterThan(0);
});
});
describe("AbstractLogger — tree-shake safety [LG-20]", () => {
const ROOT = join(__dirname, "..", "..");
it("package.json sets sideEffects: false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(ROOT, "package.json"), "utf8")
);
expect(pkg.sideEffects).toBe(false);
});
it("package.json exposes per-feature subpath exports", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(ROOT, "package.json"), "utf8")
);
const exports = pkg.exports ?? {};
// Spot-check a handful of expected subpaths
const required = ["."];
for (const sub of required) {
expect(Object.keys(exports)).toContain(sub);
}
// At least one concrete-logger subpath should exist (./console
// or ./storage or ./event), otherwise tree-shaking the
// concretes via subpath import would be impossible.
const hasConcreteSubpath = Object.keys(exports).some((k) =>
/\.\/(console|storage|event|sink|sampling|redaction|record|errors|otel)/.test(
k
)
);
expect(hasConcreteSubpath).toBe(true);
});
it("dist/ is built (or absent in clean tree)", { timeout: 5000 }, () => {
// This test passes when dist/ either does not exist (pre-build)
// or exists with the documented entry points. The shape of the
// assertion is the runtime-survivable invariant: a dist that
// exists but lacks the entry index is a real failure.
const dist = join(ROOT, "dist");
if (!existsSync(dist)) {
expect(true).toBe(true);
return;
}
const indexJs = join(dist, "index.js");
const indexDts = join(dist, "index.d.ts");
expect(existsSync(indexJs) || existsSync(indexDts)).toBe(true);
});
});

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LogEntry } from "../record/log-record.types.js";
import type { Sink } from "../sink/sink.types.js";
import { AbstractLogger } from "./abstract-logger.js";
class TestLogger extends AbstractLogger {
public written: LogEntry[] = [];
protected writeLog(entry: LogEntry): void {
this.written.push(entry);
}
}
describe("AbstractLogger — level filtering", () => {
it("logs at and above the configured level", { timeout: 5000 }, () => {
const logger = new TestLogger({ level: "warn" });
logger.debug("debug msg");
logger.info("info msg");
logger.warn("warn msg");
logger.error("error msg");
expect(logger.written).toHaveLength(2);
expect(logger.written[0].level).toBe("warn");
expect(logger.written[1].level).toBe("error");
});
it("setLevel changes the active filter", { timeout: 5000 }, () => {
const logger = new TestLogger({ level: "error" });
logger.info("ignored");
logger.setLevel("debug");
logger.info("now visible");
expect(logger.written).toHaveLength(1);
});
});
describe("AbstractLogger — enable/disable", () => {
it("disable suppresses all output", { timeout: 5000 }, () => {
const logger = new TestLogger({ level: "debug" });
logger.disable();
logger.info("muted");
expect(logger.written).toHaveLength(0);
});
it("enable restores output", { timeout: 5000 }, () => {
const logger = new TestLogger({ level: "debug" });
logger.disable();
logger.enable();
logger.info("visible");
expect(logger.written).toHaveLength(1);
});
});
describe("AbstractLogger — metrics", () => {
it("tracks total and per-level counts", { timeout: 5000 }, () => {
const logger = new TestLogger({ level: "debug" });
logger.debug("d");
logger.info("i");
logger.warn("w");
logger.error("e");
const m = logger.getMetrics();
expect(m.totalLogs).toBe(4);
expect(m.logsByLevel.debug).toBe(1);
expect(m.logsByLevel.info).toBe(1);
expect(m.logsByLevel.warn).toBe(1);
expect(m.logsByLevel.error).toBe(1);
});
it("resetMetrics clears all counters", { timeout: 5000 }, () => {
const logger = new TestLogger({ level: "debug" });
logger.info("x");
logger.resetMetrics();
const m = logger.getMetrics();
expect(m.totalLogs).toBe(0);
expect(m.droppedBySampling).toBe(0);
});
it("tracks droppedBySampling via sampler", { timeout: 5000 }, () => {
const logger = new TestLogger({
level: "debug",
sampling: { kind: "none" },
});
logger.info("will be dropped");
expect(logger.getMetrics().droppedBySampling).toBe(1);
expect(logger.written).toHaveLength(0);
});
});
describe("AbstractLogger — sink dispatch", () => {
it("writes to provided sinks", { timeout: 5000 }, () => {
const records: unknown[] = [];
const sink: Sink = {
write: (r) => {
records.push(r);
},
};
const logger = new TestLogger({ level: "info", sinks: [sink] });
logger.info("hello");
expect(records).toHaveLength(1);
});
it("tolerates a throwing sink", { timeout: 5000 }, () => {
const bad: Sink = {
write: () => {
throw new Error("oops");
},
};
const fallbackRecords: unknown[] = [];
const fallbackSink: Sink = {
write: (r) => {
fallbackRecords.push(r);
},
};
const logger = new TestLogger({
level: "info",
sinks: [bad],
fallbackSink,
});
expect(() => logger.info("risky")).not.toThrow();
expect(logger.getMetrics().droppedBySinkError).toBe(1);
expect(logger.getMetrics().droppedByBackpressure).toBe(0);
expect(fallbackRecords).toHaveLength(1);
});
});
describe("AbstractLogger — redaction", () => {
it("redacts configured paths before reaching sinks", {
timeout: 5000,
}, () => {
const records: Array<{ ctx: Record<string, unknown> }> = [];
const sink: Sink = {
write: (r) => {
records.push(r as { ctx: Record<string, unknown> });
},
};
const logger = new TestLogger({
level: "info",
sinks: [sink],
redaction: { rules: [{ kind: "path", path: "password" }] },
});
logger.info("login", { password: "secret" } as Record<string, unknown>);
expect(records[0].ctx.password).toBe("[REDACTED]");
});
});
describe("AbstractLogger — clock injection", () => {
it("uses injected clock for timestamps", { timeout: 5000 }, () => {
const fixedMs = new Date("2024-01-01T00:00:00.000Z").getTime();
const clock = () => fixedMs;
const records: Array<{ ts: string }> = [];
const sink: Sink = {
write: (r) => {
records.push(r as { ts: string });
},
};
const logger = new TestLogger({ level: "info", sinks: [sink], clock });
logger.info("timed");
expect(records[0].ts).toBe("2024-01-01T00:00:00.000Z");
});
});
describe("AbstractLogger — tag merging", () => {
it("merges global and per-call tags into ctx.__tags", {
timeout: 5000,
}, () => {
const records: Array<{ ctx: Record<string, unknown> }> = [];
const sink: Sink = {
write: (r) => {
records.push(r as { ctx: Record<string, unknown> });
},
};
const logger = new TestLogger({
level: "info",
tags: ["global"],
sinks: [sink],
});
logger.info("msg", undefined, ["local"]);
expect(records[0].ctx["__tags"]).toEqual(["global", "local"]);
});
});
describe("AbstractLogger — async sink rejection", () => {
it("increments droppedBySinkError on rejected promise", {
timeout: 5000,
}, async () => {
const bad: Sink = {
write: () => Promise.reject(new Error("async fail")),
};
const fallbackRecords: unknown[] = [];
const fallbackSink: Sink = {
write: (r) => {
fallbackRecords.push(r);
},
};
const logger = new TestLogger({
level: "info",
sinks: [bad],
fallbackSink,
});
logger.info("async-risky");
// Allow the rejected promise handler to run
await new Promise((r) => setTimeout(r, 20));
expect(logger.getMetrics().droppedBySinkError).toBe(1);
expect(logger.getMetrics().droppedByBackpressure).toBe(0);
expect(fallbackRecords).toHaveLength(1);
});
});
describe("AbstractLogger — isEnabled", () => {
it("returns false when disabled", { timeout: 5000 }, () => {
const logger = new TestLogger();
logger.disable();
expect(logger.isEnabled()).toBe(false);
});
it("returns true by default", { timeout: 5000 }, () => {
const logger = new TestLogger();
expect(logger.isEnabled()).toBe(true);
});
});
describe("AbstractLogger — rng config", () => {
it("accepts a custom rng function", { timeout: 5000 }, () => {
const rng = () => 0.5;
const logger = new TestLogger({ level: "debug", rng });
logger.info("msg");
expect(logger.written).toHaveLength(1);
});
});
describe("AbstractLogger — pre-built Sampler", () => {
it("accepts a Sampler object with decide() directly", {
timeout: 5000,
}, () => {
const sampler = { decide: () => "keep" as const };
const logger = new TestLogger({ level: "debug", sampling: sampler });
logger.info("msg");
expect(logger.written).toHaveLength(1);
});
});
describe("AbstractLogger — otel disabled", () => {
it("skips getOtelTraceContext when otel=false", { timeout: 5000 }, () => {
const records: unknown[] = [];
const sink: Sink = {
write: (r) => {
records.push(r);
},
};
const logger = new TestLogger({
level: "info",
sinks: [sink],
otel: false,
});
logger.info("msg");
expect(records).toHaveLength(1);
});
});

View File

@@ -0,0 +1,307 @@
import { metrics } from "@opentelemetry/api";
import { LOGGER_CODES, LoggerError } from "../errors/logger-error.js";
import { getOtelTraceContext } from "../otel/otel-context.js";
import { buildRecord, LOG_LEVELS, shouldLog } from "../record/log-record.js";
import type {
LogEntry,
LoggerMetrics,
LogLevel,
LogRecord,
} from "../record/log-record.types.js";
import { RedactionEngine } from "../redaction/redaction.js";
import { createSampler } from "../sampling/sampling.js";
import type { Sampler } from "../sampling/sampling.types.js";
import type { Sink } from "../sink/sink.types.js";
import type { AbstractLoggerConfig } from "./abstract-logger.types.js";
/**
* Default fallback sink — emits a single line via `console.error` whenever a
* primary sink throws or rejects. Surfaces what the primary path swallowed
* instead of silently incrementing a counter.
*/
function defaultFallbackSink(): Sink {
return {
write(record: LogRecord): void {
console.error(
"[suaveplan-logger:fallback]",
JSON.stringify(record)
);
},
};
}
export type LogLevels = Record<LogLevel, number>;
export interface Logger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> {
debug(message: string, metadata?: TMetadata, tags?: string[]): void;
info(message: string, metadata?: TMetadata, tags?: string[]): void;
warn(message: string, metadata?: TMetadata, tags?: string[]): void;
error(message: string, metadata?: TMetadata, tags?: string[]): void;
setLevel(level: LogLevel): void;
getLevel(): LogLevel;
enable(): void;
disable(): void;
isEnabled(): boolean;
getMetrics(): LoggerMetrics;
resetMetrics(): void;
}
export abstract class AbstractLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> implements Logger<TMetadata>
{
protected config: {
level: LogLevel;
enabled: boolean;
metadata: TMetadata;
tags: string[];
sinks: Sink[];
fallbackSink: Sink;
redaction?: AbstractLoggerConfig<TMetadata>["redaction"];
sampling?: AbstractLoggerConfig<TMetadata>["sampling"];
clock?: AbstractLoggerConfig<TMetadata>["clock"];
rng?: AbstractLoggerConfig<TMetadata>["rng"];
otel: boolean;
};
protected metrics: LoggerMetrics;
private readonly redaction: RedactionEngine;
private readonly sampler: Sampler;
private readonly otelEnabled: boolean;
private readonly meter = metrics.getMeter("@suaveplan/logger");
private readonly recordsCounter = this.meter.createCounter(
"logger.records_total"
);
private readonly recordSizeHistogram = this.meter.createHistogram(
"logger.record_size_bytes"
);
private readonly droppedCounter = this.meter.createCounter(
"logger.dropped_total"
);
public constructor(config: AbstractLoggerConfig<TMetadata> = {}) {
this.config = {
level: config.level ?? "info",
enabled: config.enabled ?? true,
metadata: (config.metadata ?? {}) as TMetadata,
tags: config.tags ?? [],
sinks: config.sinks ?? [],
fallbackSink: config.fallbackSink ?? defaultFallbackSink(),
otel: config.otel ?? true,
};
if (config.redaction !== undefined)
this.config.redaction = config.redaction;
if (config.sampling !== undefined)
this.config.sampling = config.sampling;
if (config.clock !== undefined) this.config.clock = config.clock;
if (config.rng !== undefined) this.config.rng = config.rng;
this.redaction = new RedactionEngine(config.redaction ?? {});
if (config.sampling) {
this.sampler =
"decide" in config.sampling
? (config.sampling as Sampler)
: createSampler(config.sampling);
} else {
this.sampler = { decide: () => "keep" };
}
this.otelEnabled = config.otel ?? true;
this.metrics = this.freshMetrics();
}
private freshMetrics(): LoggerMetrics {
return {
totalLogs: 0,
logsByLevel: { debug: 0, info: 0, warn: 0, error: 0 },
droppedBySampling: 0,
droppedByBackpressure: 0,
droppedBySinkError: 0,
};
}
public debug(message: string, metadata?: TMetadata, tags?: string[]): void {
this.log("debug", message, metadata, tags);
}
public info(message: string, metadata?: TMetadata, tags?: string[]): void {
this.log("info", message, metadata, tags);
}
public warn(message: string, metadata?: TMetadata, tags?: string[]): void {
this.log("warn", message, metadata, tags);
}
public error(message: string, metadata?: TMetadata, tags?: string[]): void {
this.log("error", message, metadata, tags);
}
public setLevel(level: LogLevel): void {
this.config.level = level;
}
public getLevel(): LogLevel {
return this.config.level;
}
public enable(): void {
this.config.enabled = true;
}
public disable(): void {
this.config.enabled = false;
}
public isEnabled(): boolean {
return this.config.enabled;
}
public getMetrics(): LoggerMetrics {
return {
...this.metrics,
logsByLevel: { ...this.metrics.logsByLevel },
};
}
public resetMetrics(): void {
this.metrics = this.freshMetrics();
}
protected log(
level: LogLevel,
message: string,
metadata?: TMetadata,
tags?: string[]
): void {
if (!this.config.enabled) return;
if (!shouldLog(this.config.level, level)) return;
const clock = this.config.clock;
const ts = clock
? new Date(clock()).toISOString()
: new Date().toISOString();
const mergedMeta: Record<string, unknown> = {
...this.config.metadata,
...metadata,
};
const mergedTags = [...this.config.tags, ...(tags ?? [])];
const ctx: Record<string, unknown> = { ...mergedMeta };
if (mergedTags.length > 0) ctx["__tags"] = mergedTags;
const redactedCtx = this.redaction.redact(ctx);
const traceCtx = this.otelEnabled ? getOtelTraceContext() : {};
const record = buildRecord(level, message, redactedCtx, ts, traceCtx);
const decision = this.sampler.decide(record);
if (decision === "drop") {
this.metrics.droppedBySampling++;
this.droppedCounter.add(1, { reason: "sampling" });
return;
}
this.updateMetrics(level, record);
this.dispatchToSinks(record);
this.writeLog(
buildLogEntry(level, message, mergedMeta as TMetadata, mergedTags)
);
}
private updateMetrics(level: LogLevel, record: LogRecord): void {
this.metrics.totalLogs++;
this.metrics.logsByLevel[level]++;
this.metrics.lastLogTime = new Date();
this.recordsCounter.add(1, { level });
try {
const size = JSON.stringify(record).length;
this.recordSizeHistogram.record(size, { level });
} catch {
// non-serialisable record — skip histogram
}
}
private dispatchToSinks(record: LogRecord): void {
const sinks = this.config.sinks;
let i = 0;
for (const sink of sinks) {
const idx = i++;
try {
const result = sink.write(record);
if (result instanceof Promise) {
result.catch((err: unknown) => {
this.handleSinkFailure(record, idx, err);
});
}
} catch (err) {
this.handleSinkFailure(record, idx, err);
}
}
}
private handleSinkFailure(
record: LogRecord,
sinkIndex: number,
cause: unknown
): void {
this.metrics.droppedBySinkError++;
this.droppedCounter.add(1, { reason: "sink_error" });
const sinkError = new LoggerError(
LOGGER_CODES.SINK,
"AbstractLogger: sink dispatch failed",
{ cause: cause instanceof Error ? cause : undefined }
);
const fallbackRecord: LogRecord = {
...record,
level: "error",
ctx: {
...record.ctx,
__sink_index: sinkIndex,
__dispatch_failure: true,
},
error: {
code: sinkError.code,
message: sinkError.message,
cause: cause instanceof Error ? cause.message : String(cause),
},
};
try {
const out = this.config.fallbackSink.write(fallbackRecord);
if (out instanceof Promise) {
// Last-ditch — if even the fallback rejects, swallow to avoid
// an unhandled rejection but never silently. We fall through
// to the bare console.error to make the failure visible.
out.catch((fallbackErr: unknown) => {
console.error(
"[suaveplan-logger:fallback-failed]",
fallbackErr
);
});
}
} catch (fallbackErr) {
console.error("[suaveplan-logger:fallback-failed]", fallbackErr);
}
}
protected abstract writeLog(entry: LogEntry<TMetadata>): void;
}
function buildLogEntry<TMetadata extends Record<string, unknown>>(
level: LogLevel,
message: string,
metadata: TMetadata,
tags: string[]
): LogEntry<TMetadata> {
return { level, message, timestamp: new Date(), metadata, tags };
}
export type {
AbstractLoggerConfig,
LogEntry,
LoggerMetrics,
LogLevel,
LogRecord,
};
export { LOG_LEVELS };

View File

@@ -0,0 +1,30 @@
import type { Clock, Rng } from "@suaveplan/types";
import type {
LogEntry,
LoggerConfig,
LoggerMetrics,
} from "../record/log-record.types.js";
import type { RedactionOptions } from "../redaction/redaction.types.js";
import type { Sampler, SamplingPolicy } from "../sampling/sampling.types.js";
import type { Sink } from "../sink/sink.types.js";
export type { LogEntry, LoggerConfig, LoggerMetrics };
export interface AbstractLoggerConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends LoggerConfig<TMetadata> {
sinks?: Sink[];
/**
* Sink that receives a `LoggerError(LOGGER.SINK)`-tagged record whenever
* a primary sink throws or rejects during dispatch. Defaults to a sink
* that writes the failure record via `console.error`. Pass an explicit
* sink (e.g. an in-memory recorder) to capture sink failures in tests.
*/
fallbackSink?: Sink;
redaction?: RedactionOptions;
sampling?: SamplingPolicy | Sampler;
clock?: Clock;
rng?: Rng;
/** Attach OTel trace/span IDs automatically (default true) */
otel?: boolean;
}

View File

@@ -0,0 +1,12 @@
export {
AbstractLogger,
type AbstractLoggerConfig,
LOG_LEVELS,
type LogEntry,
type Logger,
type LoggerMetrics,
type LogLevel,
type LogLevels,
type LogRecord,
} from "./abstract-logger";
export type { LoggerConfig } from "./abstract-logger.types";

View File

@@ -0,0 +1,372 @@
/**
* Spec-conformance tests for `openspec/specs/logging/spec.md`.
*
* Pins LG-21 (sink dispatch isolation), LG-22 (BufferedSink
* no-deadlock contract), LG-23 (MultiSink fan-out), LG-24 (generic
* Logger surface), and the dispatch-side LG-13 (LOGGER.SINK
* surfaced through fallbackSink rather than thrown out of log()).
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AbstractLogger } from "./abstract/abstract-logger.js";
import type { LogEntry, LogRecord } from "./record/log-record.types.js";
import { BufferedSink } from "./sink/buffered-sink.js";
import { MultiSink } from "./sink/sink.js";
import type { Sink } from "./sink/sink.types.js";
class TestLogger extends AbstractLogger {
public written: LogEntry[] = [];
protected writeLog(entry: LogEntry): void {
this.written.push(entry);
}
}
function makeRecord(overrides: Partial<LogRecord> = {}): LogRecord {
return {
ts: new Date().toISOString(),
level: "info",
msg: "hello",
ctx: {},
...overrides,
};
}
describe("LG-21 — Sink dispatch isolation", () => {
it("captures a sync sink throw via fallbackSink with LOGGER.SINK code", {
timeout: 5000,
}, () => {
const fallbackRecords: LogRecord[] = [];
const fallbackSink: Sink = {
write: (r) => {
fallbackRecords.push(r);
},
};
const bad: Sink = {
write: () => {
throw new Error("serializer crash");
},
};
const logger = new TestLogger({
level: "info",
sinks: [bad],
fallbackSink,
});
logger.info("boom");
expect(fallbackRecords).toHaveLength(1);
const fr = fallbackRecords[0];
const err = fr.error as { code: string; cause: string };
expect(err.code).toBe("LOGGER.SINK");
expect(err.cause).toContain("serializer crash");
expect(fr.ctx.__sink_index).toBe(0);
expect(fr.ctx.__dispatch_failure).toBe(true);
expect(logger.getMetrics().droppedBySinkError).toBe(1);
expect(logger.getMetrics().droppedByBackpressure).toBe(0);
});
it("captures an async sink rejection via fallbackSink", {
timeout: 5000,
}, async () => {
const fallbackRecords: LogRecord[] = [];
const fallbackSink: Sink = {
write: (r) => {
fallbackRecords.push(r);
},
};
const bad: Sink = {
write: () => Promise.reject(new Error("async crash")),
};
const logger = new TestLogger({
level: "info",
sinks: [bad],
fallbackSink,
});
logger.info("boom");
await new Promise((r) => setTimeout(r, 20));
expect(fallbackRecords).toHaveLength(1);
const err = fallbackRecords[0].error as {
code: string;
cause: string;
};
expect(err.code).toBe("LOGGER.SINK");
expect(err.cause).toContain("async crash");
expect(logger.getMetrics().droppedBySinkError).toBe(1);
});
it("continues dispatching when an earlier sink throws", {
timeout: 5000,
}, () => {
const goodWrites: LogRecord[] = [];
const fallbackRecords: LogRecord[] = [];
const bad: Sink = {
write: () => {
throw new Error("first failed");
},
};
const good: Sink = {
write: (r) => {
goodWrites.push(r);
},
};
const logger = new TestLogger({
level: "info",
sinks: [bad, good],
fallbackSink: {
write: (r) => {
fallbackRecords.push(r);
},
},
});
logger.info("after-bad");
expect(goodWrites).toHaveLength(1);
expect(fallbackRecords).toHaveLength(1);
expect((fallbackRecords[0].error as { code: string }).code).toBe(
"LOGGER.SINK"
);
});
it("captures a non-Error sink throw with stringified cause", {
timeout: 5000,
}, () => {
const fallbackRecords: LogRecord[] = [];
const bad: Sink = {
write: () => {
throw "string-thrown";
},
};
const logger = new TestLogger({
level: "info",
sinks: [bad],
fallbackSink: {
write: (r) => {
fallbackRecords.push(r);
},
},
});
logger.info("non-error");
expect(fallbackRecords).toHaveLength(1);
const err = fallbackRecords[0].error as { code: string; cause: string };
expect(err.code).toBe("LOGGER.SINK");
expect(err.cause).toBe("string-thrown");
});
it("does not throw out of log() on sink failure", { timeout: 5000 }, () => {
const bad: Sink = {
write: () => {
throw new Error("nope");
},
};
const logger = new TestLogger({
level: "info",
sinks: [bad],
fallbackSink: { write: () => {} },
});
expect(() => logger.info("safe")).not.toThrow();
});
it("default fallbackSink writes via console.error", {
timeout: 5000,
}, () => {
const calls: unknown[][] = [];
const orig = console.error;
console.error = (...args: unknown[]) => {
calls.push(args);
};
try {
const bad: Sink = {
write: () => {
throw new Error("default-fb");
},
};
// No fallbackSink supplied — exercises the default
const logger = new TestLogger({
level: "info",
sinks: [bad],
});
logger.info("uses-default");
expect(calls.length).toBeGreaterThan(0);
expect(String(calls[0][0])).toContain(
"[suaveplan-logger:fallback]"
);
} finally {
console.error = orig;
}
});
it("swallows a fallbackSink failure into a console.error of last resort", {
timeout: 5000,
}, () => {
const calls: unknown[][] = [];
const orig = console.error;
console.error = (...args: unknown[]) => {
calls.push(args);
};
try {
const bad: Sink = {
write: () => {
throw new Error("primary-bad");
},
};
const fallbackBad: Sink = {
write: () => {
throw new Error("fallback-also-bad");
},
};
const logger = new TestLogger({
level: "info",
sinks: [bad],
fallbackSink: fallbackBad,
});
expect(() => logger.info("layered")).not.toThrow();
const text = calls.map((c) => c.map(String).join(" ")).join(" ");
expect(text).toContain("fallback-failed");
} finally {
console.error = orig;
}
});
it("swallows an async fallbackSink rejection into console.error", {
timeout: 5000,
}, async () => {
const calls: unknown[][] = [];
const orig = console.error;
console.error = (...args: unknown[]) => {
calls.push(args);
};
try {
const bad: Sink = {
write: () => {
throw new Error("primary-bad");
},
};
const fallbackBad: Sink = {
write: () => Promise.reject(new Error("fallback-async-bad")),
};
const logger = new TestLogger({
level: "info",
sinks: [bad],
fallbackSink: fallbackBad,
});
logger.info("layered-async");
await new Promise((r) => setTimeout(r, 20));
const text = calls.map((c) => c.map(String).join(" ")).join(" ");
expect(text).toContain("fallback-failed");
} finally {
console.error = orig;
}
});
});
describe("LG-22 — BufferedSink no-deadlock contract", () => {
it("block policy makes forward progress without an external flush", {
timeout: 5000,
}, async () => {
const written: LogRecord[] = [];
const inner: Sink = {
write: async (r) => {
written.push(r);
},
};
const buf = new BufferedSink({
sink: inner,
maxQueueSize: 1,
overflowPolicy: "block",
});
// Three writes with capacity=1 — second and third must drain
// inline rather than parking forever.
await buf.write(makeRecord({ msg: "a" }));
await buf.write(makeRecord({ msg: "b" }));
await buf.write(makeRecord({ msg: "c" }));
expect(written).toHaveLength(2);
await buf.flush();
expect(written).toHaveLength(3);
expect(written.map((r) => r.msg)).toEqual(["a", "b", "c"]);
});
it("constructor rejects non-positive maxQueueSize with LOGGER.CONFIG", {
timeout: 5000,
}, () => {
const inner: Sink = { write: () => {} };
expect(() => {
new BufferedSink({ sink: inner, maxQueueSize: 0 });
}).toThrow(/maxQueueSize/);
expect(() => {
new BufferedSink({ sink: inner, maxQueueSize: -1 });
}).toThrow(/maxQueueSize/);
expect(() => {
new BufferedSink({ sink: inner, maxQueueSize: 1.5 });
}).toThrow(/maxQueueSize/);
});
it("wraps inline-drain failure as LOGGER.SINK during write()", {
timeout: 5000,
}, async () => {
const inner: Sink = {
write: () => Promise.reject(new Error("drain-broke")),
};
const buf = new BufferedSink({
sink: inner,
maxQueueSize: 1,
overflowPolicy: "block",
});
await buf.write(makeRecord({ msg: "first" }));
await expect(buf.write(makeRecord({ msg: "second" }))).rejects.toThrow(
/inner sink write failed/
);
});
it("wraps non-Error inline-drain failure with undefined cause", {
timeout: 5000,
}, async () => {
const inner: Sink = {
// throw a non-Error string to cover the `err instanceof Error ? err : undefined` branch
write: () => Promise.reject("string-error"),
};
const buf = new BufferedSink({
sink: inner,
maxQueueSize: 1,
overflowPolicy: "block",
});
await buf.write(makeRecord({ msg: "first" }));
await expect(buf.write(makeRecord({ msg: "second" }))).rejects.toThrow(
/inner sink write failed/
);
});
});
describe("LG-23 — MultiSink fan-out semantics", () => {
it("writes the same record to every wrapped sink", {
timeout: 5000,
}, async () => {
const a: LogRecord[] = [];
const b: LogRecord[] = [];
const multi = new MultiSink([
{ write: (r) => void a.push(r) },
{ write: (r) => void b.push(r) },
]);
await multi.write(makeRecord({ msg: "fan" }));
expect(a).toHaveLength(1);
expect(b).toHaveLength(1);
expect(a[0].msg).toBe("fan");
expect(b[0].msg).toBe("fan");
});
});
describe("LG-24 — Generic Logger surface", () => {
it("accepts a typed metadata generic and threads it through", {
timeout: 5000,
}, () => {
interface AppMeta extends Record<string, unknown> {
requestId: string;
userId?: string;
}
class AppLogger extends AbstractLogger<AppMeta> {
public last?: LogEntry<AppMeta>;
protected writeLog(entry: LogEntry<AppMeta>): void {
this.last = entry;
}
}
const logger = new AppLogger({ level: "info" });
logger.info("typed", { requestId: "abc-123" });
expect(logger.last?.metadata?.requestId).toBe("abc-123");
});
});

View File

@@ -0,0 +1,70 @@
# console-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ConsoleLogger` extends `AbstractLogger` to provide human-readable, ANSI-coloured terminal output — the default logger for development and local debugging. It wires a `ConsoleSink` as the first element of its sink chain, so the full enterprise pipeline (level filtering, redaction, sampling, OTel correlation, metrics) runs before any output reaches the terminal. Configuration mirrors the existing v1 API exactly, preserving backward compatibility.
## Features
- ANSI colour per severity level (configurable at construction and runtime)
- ISO timestamp prefix (configurable)
- Optional tag rendering as `#tag` tokens
- Optional metadata rendering as compact JSON suffix
- All `AbstractLogger` features: redaction, sampling, OTel, clock injection, `getMetrics()`
## Usage: Basic Example
```ts
import { ConsoleLogger } from "@suaveplan/logger/console";
const log = new ConsoleLogger({ level: "info" });
log.info("Server started", { port: 3000 });
```
## Usage: Advanced Example
```ts
import { ConsoleLogger } from "@suaveplan/logger/console";
const log = new ConsoleLogger({
level: "debug",
colors: true,
timestamp: true,
includeMetadata: true,
tags: ["api"],
redaction: { rules: [{ kind: "pattern", pattern: /token|secret/i }] },
});
log.info("User authenticated", { userId: "u-1", token: "abc" }, ["auth"]);
// [2024-…] [INFO] [#api #auth] User authenticated
// { "userId": "u-1", "token": "[REDACTED]" }
```
## API Reference
### `class ConsoleLogger<TMetadata> extends AbstractLogger<TMetadata>`
**Constructor**: `new ConsoleLogger(config?: ConsoleLoggerConfig<TMetadata>)`
Extends `AbstractLoggerConfig` with:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `colors` | `boolean` | `true` | ANSI colour codes |
| `timestamp` | `boolean` | `true` | ISO timestamp prefix |
| `includeMetadata` | `boolean` | `false` | Compact JSON metadata suffix |
| `includeTags` | `boolean` | `true` | `#tag` tokens inline |
#### Runtime mutation methods
- `setColors(enabled: boolean)`
- `setTimestamp(enabled: boolean)`
- `setIncludeMetadata(enabled: boolean)`
- `setIncludeTags(enabled: boolean)`
## Implementation Notes
The `writeLog()` hook is a no-op — all output is delegated to `ConsoleSink` registered in the `sinks` array. This ensures records pass through redaction and sampling (handled by `AbstractLogger`) before any console call. Runtime mutation methods write directly to the `ConsoleSink`'s protected fields.

View File

@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { ConsoleLogger } from "./console-logger.js";
describe("ConsoleLogger", () => {
it("writes to console at the correct method", { timeout: 5000 }, () => {
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
const logger = new ConsoleLogger({ colors: false, timestamp: false });
logger.info("hello");
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
});
it("respects level filtering", { timeout: 5000 }, () => {
const debugSpy = vi
.spyOn(console, "debug")
.mockImplementation(() => {});
const logger = new ConsoleLogger({ level: "warn", colors: false });
logger.debug("nope");
expect(debugSpy).not.toHaveBeenCalled();
debugSpy.mockRestore();
});
it("tracks metrics on write", { timeout: 5000 }, () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const logger = new ConsoleLogger({ level: "debug", colors: false });
logger.warn("counted");
expect(logger.getMetrics().logsByLevel.warn).toBe(1);
vi.restoreAllMocks();
});
it("setColors toggles colour output", { timeout: 5000 }, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(String(args[0]));
});
const logger = new ConsoleLogger({ colors: true, timestamp: false });
logger.info("coloured");
const withColor = lines[0];
lines.length = 0;
logger.setColors(false);
logger.info("plain");
const noColor = lines[0];
expect(withColor).not.toBe(noColor);
vi.restoreAllMocks();
});
it("setTimestamp toggles timestamp prefix", { timeout: 5000 }, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(String(args[0]));
});
const logger = new ConsoleLogger({ colors: false, timestamp: true });
logger.info("msg");
const withTs = lines[0];
lines.length = 0;
logger.setTimestamp(false);
logger.info("msg");
const withoutTs = lines[0];
expect(withTs).toContain("[");
expect(withoutTs).not.toContain("Z]"); // no ISO timestamp
vi.restoreAllMocks();
});
it("setIncludeMetadata shows metadata", { timeout: 5000 }, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(String(args[0]));
});
const logger = new ConsoleLogger({ colors: false, timestamp: false });
logger.setIncludeMetadata(true);
logger.info("msg", { key: "val" } as Record<string, unknown>);
expect(lines[0]).toContain("key");
vi.restoreAllMocks();
});
it("setIncludeTags hides tags when false", { timeout: 5000 }, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(String(args[0]));
});
const logger = new ConsoleLogger({
colors: false,
timestamp: false,
tags: ["t1"],
});
logger.setIncludeTags(false);
logger.info("msg");
expect(lines[0]).not.toContain("#t1");
vi.restoreAllMocks();
});
it("disable/enable works", { timeout: 5000 }, () => {
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
const logger = new ConsoleLogger({ colors: false });
logger.disable();
logger.info("silenced");
expect(spy).not.toHaveBeenCalled();
logger.enable();
logger.info("audible");
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
});
it("constructor includeMetadata option is forwarded to sink", {
timeout: 5000,
}, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(String(args[0]));
});
const logger = new ConsoleLogger({
colors: false,
timestamp: false,
includeMetadata: true,
});
logger.info("msg", { key: "ctor-val" } as Record<string, unknown>);
expect(lines[0]).toContain("key");
vi.restoreAllMocks();
});
it("defaults work when constructed with no arguments", {
timeout: 5000,
}, () => {
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
// No args at all → hits default `= {}` branch in constructor
const logger = new ConsoleLogger();
logger.info("defaults");
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it("constructor includeTags option is forwarded to sink", {
timeout: 5000,
}, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(String(args[0]));
});
const logger = new ConsoleLogger({
colors: false,
timestamp: false,
includeTags: true,
tags: ["ctor-tag"],
});
logger.info("msg");
expect(lines[0]).toContain("#ctor-tag");
vi.restoreAllMocks();
});
});

View File

@@ -0,0 +1,44 @@
import { AbstractLogger } from "../abstract/abstract-logger.js";
import type { LogEntry } from "../record/log-record.types.js";
import { ConsoleSink } from "../sink/console-sink.js";
import type { ConsoleLoggerConfig } from "./console-logger.types.js";
export class ConsoleLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends AbstractLogger<TMetadata> {
private readonly consoleSink: ConsoleSink;
public constructor(config: ConsoleLoggerConfig<TMetadata> = {}) {
const sinkOpts: import("../sink/sink.types.js").ConsoleSinkOptions = {};
if (config.colors !== undefined) sinkOpts.colors = config.colors;
if (config.timestamp !== undefined)
sinkOpts.timestamp = config.timestamp;
if (config.includeMetadata !== undefined)
sinkOpts.includeMetadata = config.includeMetadata;
if (config.includeTags !== undefined)
sinkOpts.includeTags = config.includeTags;
const consoleSink = new ConsoleSink(sinkOpts);
super({ ...config, sinks: [consoleSink, ...(config.sinks ?? [])] });
this.consoleSink = consoleSink;
}
protected writeLog(_entry: LogEntry<TMetadata>): void {
// Output goes through ConsoleSink via the sinks pipeline in AbstractLogger.
}
public setColors(enabled: boolean): void {
this.consoleSink.colors = enabled;
}
public setTimestamp(enabled: boolean): void {
this.consoleSink.timestamp = enabled;
}
public setIncludeMetadata(enabled: boolean): void {
this.consoleSink.includeMetadata = enabled;
}
public setIncludeTags(enabled: boolean): void {
this.consoleSink.includeTags = enabled;
}
}

View File

@@ -0,0 +1,10 @@
import type { AbstractLoggerConfig } from "../abstract/abstract-logger.types.js";
export interface ConsoleLoggerConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends AbstractLoggerConfig<TMetadata> {
colors?: boolean;
timestamp?: boolean;
includeMetadata?: boolean;
includeTags?: boolean;
}

View File

@@ -0,0 +1,2 @@
export { ConsoleLogger } from "./console-logger";
export type { ConsoleLoggerConfig } from "./console-logger.types";

View File

@@ -0,0 +1,72 @@
# error-bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-bridge` connects `@suaveplan/error`'s `SuaveplanError` to the logger, providing a single function (`logSuaveplanError`) that routes a structured error to whichever logger was installed via `installSuaveplanErrorInterceptor`. The install function is idempotent — calling it multiple times is safe and returns the same uninstall handle, preventing double-registration in environments where multiple modules bootstrap logging.
The implementation differs from the main-branch version: rather than hooking into a global interceptor registry (which does not yet exist in `@suaveplan/error`), error routing is explicit — callers invoke `logSuaveplanError(err)` in their catch blocks. `installSuaveplanErrorInterceptor` still establishes the logger reference and provides the idempotency/cleanup contract.
## Features
- `installSuaveplanErrorInterceptor(logger?)` — stores a logger; defaults to a `ConsoleLogger`; idempotent
- `logSuaveplanError(err)` — routes any `SuaveplanError` to the active logger at `"error"` level with code, context, stack, and serialised JSON payload
- `SEVERITY_TO_LEVEL` — mapping from `SuaveplanError` severity strings to `LogLevel`
- `__resetDefaultLoggerForTests()` — test-only helper to clear all state between test cases
## Usage: Basic Example
```ts
import { installSuaveplanErrorInterceptor, logSuaveplanError } from "@suaveplan/logger/error-bridge";
import { ConsoleLogger } from "@suaveplan/logger/console";
const logger = new ConsoleLogger({ level: "info" });
const uninstall = installSuaveplanErrorInterceptor(logger);
try {
await doRiskyThing();
} catch (err) {
if (err instanceof SuaveplanError) logSuaveplanError(err);
}
```
## Usage: Advanced Example
```ts
import { installSuaveplanErrorInterceptor, __resetDefaultLoggerForTests } from "@suaveplan/logger/error-bridge";
// In bootstrap:
const uninstall = installSuaveplanErrorInterceptor(); // uses default ConsoleLogger
// In tests (afterEach):
afterEach(() => __resetDefaultLoggerForTests());
// Multiple modules can safely call install — only the first call registers:
installSuaveplanErrorInterceptor(); // returns same uninstall as above
```
## API Reference
### `installSuaveplanErrorInterceptor(logger?: Logger): UninstallErrorInterceptor`
Stores `logger` (or a default `ConsoleLogger`) as the active error sink. Returns the uninstall function. Re-calling returns the existing uninstall without side effects.
### `logSuaveplanError(err: SuaveplanError): void`
Routes `err` to the active logger at `"error"` level. Context includes `code`, `context`, `stack`, and the JSON-serialised wire payload from `toJSON(err)`.
### `SEVERITY_TO_LEVEL: SeverityLevelMap`
```ts
{ fatal: "error", error: "error", warning: "warn", info: "info" }
```
### `__resetDefaultLoggerForTests(): void`
Clears all module-level state. Call in `afterEach` when testing code that uses the error bridge.
## Implementation Notes
The active logger is stored in module-level state (a singleton pattern). This is intentional — bootstrap code typically runs once, and the idempotency guard prevents multiple callers from interfering. `__resetDefaultLoggerForTests` exists as an escape hatch for test environments where module state must be reset between cases.

View File

@@ -0,0 +1,144 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { Logger } from "../abstract/abstract-logger.js";
import {
__resetDefaultLoggerForTests,
installSuaveplanErrorInterceptor,
logSuaveplanError,
SEVERITY_TO_LEVEL,
} from "./error-bridge.js";
const _TEST_CODES = defineCodes("TEST", ["ERR"] as const);
class TestError extends SuaveplanError<"TEST.ERR"> {
public constructor(msg: string) {
super("TEST.ERR", msg);
}
}
function makeLogger() {
const calls: Array<{ level: string; msg: string }> = [];
const logger: Logger = {
debug: (m) => calls.push({ level: "debug", msg: m }),
info: (m) => calls.push({ level: "info", msg: m }),
warn: (m) => calls.push({ level: "warn", msg: m }),
error: (m) => calls.push({ level: "error", msg: m }),
setLevel: vi.fn(),
getLevel: vi.fn(() => "info" as const),
enable: vi.fn(),
disable: vi.fn(),
isEnabled: vi.fn(() => true),
getMetrics: vi.fn(() => ({
totalLogs: 0,
logsByLevel: { debug: 0, info: 0, warn: 0, error: 0 },
droppedBySampling: 0,
droppedByBackpressure: 0,
})),
resetMetrics: vi.fn(),
};
return { logger, calls };
}
afterEach(() => {
__resetDefaultLoggerForTests();
});
describe("SEVERITY_TO_LEVEL", () => {
it("maps fatal and error to error level", { timeout: 5000 }, () => {
expect(SEVERITY_TO_LEVEL.fatal).toBe("error");
expect(SEVERITY_TO_LEVEL.error).toBe("error");
});
it("maps warning to warn and info to info", { timeout: 5000 }, () => {
expect(SEVERITY_TO_LEVEL.warning).toBe("warn");
expect(SEVERITY_TO_LEVEL.info).toBe("info");
});
});
describe("installSuaveplanErrorInterceptor", () => {
it("returns an uninstall function", { timeout: 5000 }, () => {
const { logger } = makeLogger();
const uninstall = installSuaveplanErrorInterceptor(logger);
expect(typeof uninstall).toBe("function");
uninstall();
});
it("is idempotent — second call returns same uninstall", {
timeout: 5000,
}, () => {
const { logger } = makeLogger();
const first = installSuaveplanErrorInterceptor(logger);
const second = installSuaveplanErrorInterceptor(logger);
expect(first).toBe(second);
first();
});
it("uninstall resets the interceptor", { timeout: 5000 }, () => {
const { logger } = makeLogger();
const uninstall = installSuaveplanErrorInterceptor(logger);
uninstall();
// After uninstall, a new install should succeed
const second = installSuaveplanErrorInterceptor(logger);
expect(second).not.toBe(uninstall);
second();
});
});
describe("logSuaveplanError", () => {
it("routes error to the installed logger", { timeout: 5000 }, () => {
const { logger, calls } = makeLogger();
installSuaveplanErrorInterceptor(logger);
const err = new TestError("something broke");
logSuaveplanError(err);
expect(calls).toHaveLength(1);
expect(calls[0].level).toBe("error");
expect(calls[0].msg).toBe("something broke");
});
it("falls back to default ConsoleLogger when no interceptor installed", {
timeout: 5000,
}, () => {
// No installSuaveplanErrorInterceptor called — triggers getDefaultLogger() creating new instance
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const err = new TestError("unhandled");
expect(() => logSuaveplanError(err)).not.toThrow();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it("reuses cached default logger on subsequent calls", {
timeout: 5000,
}, () => {
// Second call hits the false branch of `if (defaultLogger === null)` in getDefaultLogger()
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
logSuaveplanError(new TestError("first"));
logSuaveplanError(new TestError("second")); // reuses cached defaultLogger
expect(spy).toHaveBeenCalledTimes(2);
spy.mockRestore();
});
});
describe("installSuaveplanErrorInterceptor (no logger arg)", () => {
it("uses default ConsoleLogger when no logger argument provided", {
timeout: 5000,
}, () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const uninstall = installSuaveplanErrorInterceptor(); // no logger arg → triggers `?? getDefaultLogger()`
logSuaveplanError(new TestError("via-default"));
expect(spy).toHaveBeenCalled();
uninstall();
spy.mockRestore();
});
});
describe("__resetDefaultLoggerForTests", () => {
it("clears state for test isolation", { timeout: 5000 }, () => {
const { logger } = makeLogger();
installSuaveplanErrorInterceptor(logger);
__resetDefaultLoggerForTests();
// After reset, a fresh install should work
const uninstall = installSuaveplanErrorInterceptor(logger);
expect(uninstall).toBeDefined();
uninstall();
});
});

View File

@@ -0,0 +1,61 @@
import { type SuaveplanError, toJSON } from "@suaveplan/error";
import type { Logger } from "../abstract/abstract-logger.js";
import { ConsoleLogger } from "../console/console-logger.js";
import type {
SeverityLevelMap,
UninstallErrorInterceptor,
} from "./error-bridge.types.js";
export const SEVERITY_TO_LEVEL: SeverityLevelMap = {
fatal: "error",
error: "error",
warning: "warn",
info: "info",
};
let installed: UninstallErrorInterceptor | null = null;
let activeLogger: Logger | null = null;
let defaultLogger: Logger | null = null;
function getDefaultLogger(): Logger {
if (defaultLogger === null) {
defaultLogger = new ConsoleLogger({
level: "info",
includeMetadata: true,
});
}
return defaultLogger;
}
export function installSuaveplanErrorInterceptor(
logger?: Logger
): UninstallErrorInterceptor {
if (installed !== null) return installed;
activeLogger = logger ?? getDefaultLogger();
const uninstall: UninstallErrorInterceptor = () => {
activeLogger = null;
installed = null;
};
installed = uninstall;
return uninstall;
}
export function logSuaveplanError(err: SuaveplanError): void {
const sink = activeLogger ?? getDefaultLogger();
const payload = toJSON(err);
const meta = {
code: err.code,
context: err.context,
stack: err.stack,
error: payload,
} as Record<string, unknown>;
sink.error(err.message, meta);
}
export function __resetDefaultLoggerForTests(): void {
defaultLogger = null;
installed = null;
activeLogger = null;
}

View File

@@ -0,0 +1,8 @@
import type { LogLevel } from "../record/log-record.types.js";
export type SeverityLevelMap = Record<string, LogLevel>;
export type UninstallErrorInterceptor = () => void;
export interface InstallErrorInterceptorOptions {
logger?: import("../abstract/abstract-logger.js").Logger;
}

View File

@@ -0,0 +1,11 @@
export {
__resetDefaultLoggerForTests,
installSuaveplanErrorInterceptor,
logSuaveplanError,
SEVERITY_TO_LEVEL,
} from "./error-bridge";
export type {
InstallErrorInterceptorOptions,
SeverityLevelMap,
UninstallErrorInterceptor,
} from "./error-bridge.types";

View File

@@ -0,0 +1,2 @@
export { LOGGER_CODES, LoggerError } from "./logger-error";
export type { LoggerErrorCode } from "./logger-error.types";

View File

@@ -0,0 +1,81 @@
# logger-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LoggerError` is the structured error class used by every failure path in `@suaveplan/logger`. It extends `SuaveplanError` from `@suaveplan/error`, ensuring all logger errors carry a stable, machine-readable code, an optional cause chain, and a typed context payload. Consumer code can discriminate on `err.code` without string-matching the message.
`LOGGER_CODES` is a frozen constant record produced by `defineCodes` that maps each failure reason to its fully-qualified `"LOGGER.<REASON>"` string. Using the constant instead of raw strings ensures a typo is caught at compile time.
## Features
- Extends `SuaveplanError<LoggerErrorCode>` — inherits `code`, `cause`, `context`, `toJSON()`, `toSpanEvent()`, and severity fields
- Four stable codes: `LOGGER.SINK`, `LOGGER.SERIALIZE`, `LOGGER.CONFIG`, `LOGGER.FLUSH`
- Frozen `LOGGER_CODES` constant for type-safe code references
## Usage: Basic Example
```ts
import { LOGGER_CODES, LoggerError } from "@suaveplan/logger/errors";
throw new LoggerError(LOGGER_CODES.SINK, "Sink write failed", {
cause: originalError,
});
```
## Usage: Advanced Example
```ts
import { LOGGER_CODES, LoggerError } from "@suaveplan/logger/errors";
try {
await sink.write(record);
} catch (err) {
throw new LoggerError(LOGGER_CODES.SINK, "sink.write threw", {
cause: err instanceof Error ? err : undefined,
context: { sinkType: "JsonSink", recordLevel: record.level },
});
}
// Consumer discrimination:
if (caughtErr instanceof LoggerError && caughtErr.code === LOGGER_CODES.FLUSH) {
// handle flush-aborted case specifically
}
```
## API Reference
### `LOGGER_CODES`
```ts
const LOGGER_CODES: {
readonly SINK: "LOGGER.SINK";
readonly SERIALIZE: "LOGGER.SERIALIZE";
readonly CONFIG: "LOGGER.CONFIG";
readonly FLUSH: "LOGGER.FLUSH";
}
```
Frozen record of all stable error codes for `@suaveplan/logger`.
### `class LoggerError extends SuaveplanError<LoggerErrorCode>`
**Constructor**: `new LoggerError(code, messageKey, options?)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `code` | `LoggerErrorCode` | Stable machine-readable code |
| `messageKey` | `string` | Human-readable message or i18n key |
| `options` | `SuaveplanErrorOptions?` | Optional `{ cause, context, severity }` |
Inherits all `SuaveplanError` methods: `toJSON()`, `toSpanEvent()`, `walkCauses()`.
### `type LoggerErrorCode`
Union of all four stable code strings: `"LOGGER.SINK" | "LOGGER.SERIALIZE" | "LOGGER.CONFIG" | "LOGGER.FLUSH"`.
## Implementation Notes
`LoggerError` delegates everything to `SuaveplanError` — the constructor body is intentionally minimal. The four codes cover the complete surface: sink I/O failures (`SINK`), JSON serialisation failures (`SERIALIZE`), invalid configuration detected at construction time (`CONFIG`), and flush cancellation via `AbortSignal` (`FLUSH`). Adding new codes requires updating the `LOGGER_CODES` call and the `LoggerErrorCode` union — both are in this file to keep changes atomic.

View File

@@ -0,0 +1,50 @@
import { SuaveplanError, toJSON } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOGGER_CODES, LoggerError } from "./logger-error.js";
describe("LOGGER_CODES", () => {
it("defines all four stable codes", { timeout: 5000 }, () => {
expect(LOGGER_CODES.SINK).toBe("LOGGER.SINK");
expect(LOGGER_CODES.SERIALIZE).toBe("LOGGER.SERIALIZE");
expect(LOGGER_CODES.CONFIG).toBe("LOGGER.CONFIG");
expect(LOGGER_CODES.FLUSH).toBe("LOGGER.FLUSH");
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(LOGGER_CODES)).toBe(true);
});
});
describe("LoggerError", () => {
it("is a SuaveplanError", { timeout: 5000 }, () => {
const err = new LoggerError("LOGGER.SINK", "sink failed");
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(LoggerError);
});
it("carries the stable code", { timeout: 5000 }, () => {
const err = new LoggerError("LOGGER.SERIALIZE", "serialize failed");
expect(err.code).toBe("LOGGER.SERIALIZE");
});
it("stores cause and context", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new LoggerError("LOGGER.FLUSH", "flush aborted", {
cause,
context: { bytesLost: 42 },
});
expect(err.cause).toBe(cause);
expect(err.context).toMatchObject({ bytesLost: 42 });
});
it("has a human-readable message", { timeout: 5000 }, () => {
const err = new LoggerError("LOGGER.CONFIG", "invalid level");
expect(err.message).toContain("invalid level");
});
it("serialises to JSON with stable shape", { timeout: 5000 }, () => {
const err = new LoggerError("LOGGER.SINK", "write failed");
const json = toJSON(err);
expect(json).toMatchObject({ code: "LOGGER.SINK" });
});
});

View File

@@ -0,0 +1,20 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
import type { LoggerErrorCode } from "./logger-error.types.js";
export const LOGGER_CODES = defineCodes("LOGGER", [
"SINK",
"SERIALIZE",
"CONFIG",
"FLUSH",
] as const);
export class LoggerError extends SuaveplanError<LoggerErrorCode> {
public constructor(
code: LoggerErrorCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,5 @@
export type LoggerErrorCode =
| "LOGGER.SINK"
| "LOGGER.SERIALIZE"
| "LOGGER.CONFIG"
| "LOGGER.FLUSH";

View File

@@ -0,0 +1,89 @@
# event-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EventLogger` routes each log entry through an `EventBusLike<LoggerEvents>` emitter after the standard `AbstractLogger` pipeline (level filtering, redaction, sampling, OTel). It exists for environments where other parts of an application need to react to log events — for example, a UI component that surfaces recent errors, a test harness that asserts on emitted entries, or an aggregator that combines logs from multiple services. The emitter is injected so any conformant `EventBusLike` works (including `@suaveplan/events`' `EventBus`).
## Features
- Emits `"log"` for every accepted record
- Emits `"log:debug"`, `"log:info"`, `"log:warn"`, `"log:error"` per level
- Emits `"metrics:updated"` after every log with the current `LoggerMetrics` snapshot
- `on*/off*` helper methods for typed subscription/unsubscription
- Proper unsubscription via the `() => void` handle returned by `EventBusLike.on`
- Compatible with any `EventBusLike<LoggerEvents<TMetadata>>` — no hard dep on `@suaveplan/events`
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { EventLogger } from "@suaveplan/logger/event";
const bus = new EventBus();
const logger = new EventLogger(bus, { level: "info" });
logger.onError((entry) => {
alerting.report(entry.message, entry.metadata);
});
logger.error("Payment gateway unreachable");
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { EventLogger } from "@suaveplan/logger/event";
const bus = new EventBus();
const logger = new EventLogger(bus, { level: "debug" });
// Subscribe
const handleInfo = (entry) => console.log("[INFO]", entry.message);
logger.onInfo(handleInfo);
// Log
logger.info("Started");
// Unsubscribe
logger.offInfo(handleInfo);
```
## API Reference
### `class EventLogger<TMetadata> extends AbstractLogger<TMetadata>`
**Constructor**: `new EventLogger(emitter, config?)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `emitter` | `LoggerEventBus<TMetadata>` | Any `EventBusLike` conformant bus |
| `config` | `EventLoggerConfig<TMetadata>?` | Inherits all `AbstractLoggerConfig` options |
#### Subscription helpers (all return `this`)
`onLog` / `offLog`, `onDebug` / `offDebug`, `onInfo` / `offInfo`, `onWarn` / `offWarn`, `onError` / `offError`, `onMetricsUpdate` / `offMetricsUpdate`
#### `getEmitter(): LoggerEventBus<TMetadata>`
Returns the injected bus.
### `interface LoggerEvents<TMetadata>`
```ts
{
"log": LogEntry<TMetadata>;
"log:debug": LogEntry<TMetadata>;
"log:info": LogEntry<TMetadata>;
"log:warn": LogEntry<TMetadata>;
"log:error": LogEntry<TMetadata>;
"metrics:updated": LoggerMetrics;
}
```
## Implementation Notes
Unsubscription is implemented by storing the `() => void` cleanup function returned by `emitter.on()` in a `WeakMap<listener, cleanup>`. Calling `off*` looks up and invokes the cleanup. The `WeakMap` avoids memory leaks when the listener function is garbage collected without explicit unsubscription.

View File

@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { LogEntry } from "../record/log-record.types.js";
import { EventLogger } from "./event-logger.js";
function makeEventBus() {
const listeners = new Map<string, Array<(payload: unknown) => void>>();
return {
on: vi.fn((event: string, listener: (payload: unknown) => void) => {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(listener);
return () => {
const arr = listeners.get(event);
if (arr) {
const idx = arr.indexOf(listener);
if (idx !== -1) arr.splice(idx, 1);
}
};
}),
emit: vi.fn((event: string, payload: unknown) => {
const arr = listeners.get(event);
if (arr) for (const fn of arr) fn(payload);
}),
_listeners: listeners,
};
}
describe("EventLogger", () => {
it("emits log event on write", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const entries: LogEntry[] = [];
logger.onLog((e) => entries.push(e));
logger.info("hello");
expect(entries).toHaveLength(1);
expect(entries[0].message).toBe("hello");
expect(entries[0].level).toBe("info");
});
it("emits level-specific event", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const warns: LogEntry[] = [];
logger.onWarn((e) => warns.push(e));
logger.warn("w");
logger.info("i");
expect(warns).toHaveLength(1);
});
it("emits metrics:updated after each log", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const updates: unknown[] = [];
logger.onMetricsUpdate((m) => updates.push(m));
logger.info("x");
logger.info("y");
expect(updates).toHaveLength(2);
});
it("offLog unsubscribes the listener", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const entries: LogEntry[] = [];
const listener = (e: LogEntry) => entries.push(e);
logger.onLog(listener);
logger.info("first");
logger.offLog(listener);
logger.info("second");
expect(entries).toHaveLength(1);
});
it("getEmitter returns the bus", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus);
expect(logger.getEmitter()).toBe(bus);
});
it("does not emit when level is filtered", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "error" });
const entries: LogEntry[] = [];
logger.onLog((e) => entries.push(e));
logger.info("filtered");
expect(entries).toHaveLength(0);
});
it("onDebug receives debug entries", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const debugEntries: LogEntry[] = [];
logger.onDebug((e) => debugEntries.push(e));
logger.debug("dbg");
logger.info("inf");
expect(debugEntries).toHaveLength(1);
expect(debugEntries[0].level).toBe("debug");
});
it("onInfo receives info entries", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const infoEntries: LogEntry[] = [];
logger.onInfo((e) => infoEntries.push(e));
logger.info("info");
logger.debug("debug");
expect(infoEntries).toHaveLength(1);
});
it("onError receives error entries", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const errorEntries: LogEntry[] = [];
logger.onError((e) => errorEntries.push(e));
logger.error("err");
expect(errorEntries).toHaveLength(1);
});
it("offDebug/offInfo/offWarn/offError unsubscribe correctly", {
timeout: 5000,
}, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const debugEntries: LogEntry[] = [];
const infoEntries: LogEntry[] = [];
const warnEntries: LogEntry[] = [];
const errorEntries: LogEntry[] = [];
const dl = (e: LogEntry) => debugEntries.push(e);
const il = (e: LogEntry) => infoEntries.push(e);
const wl = (e: LogEntry) => warnEntries.push(e);
const el = (e: LogEntry) => errorEntries.push(e);
logger.onDebug(dl).onInfo(il).onWarn(wl).onError(el);
logger.debug("d");
logger.info("i");
logger.warn("w");
logger.error("e");
logger.offDebug(dl).offInfo(il).offWarn(wl).offError(el);
logger.debug("d2");
logger.info("i2");
logger.warn("w2");
logger.error("e2");
expect(debugEntries).toHaveLength(1);
expect(infoEntries).toHaveLength(1);
expect(warnEntries).toHaveLength(1);
expect(errorEntries).toHaveLength(1);
});
it("offLog is a no-op for unregistered listener", { timeout: 5000 }, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const neverRegistered = (_e: LogEntry) => {};
expect(() => logger.offLog(neverRegistered)).not.toThrow();
});
it("offMetricsUpdate unsubscribes metrics listener", {
timeout: 5000,
}, () => {
const bus = makeEventBus();
const logger = new EventLogger(bus, { level: "debug" });
const updates: unknown[] = [];
const listener = (m: unknown) => updates.push(m);
logger.onMetricsUpdate(
listener as Parameters<typeof logger.onMetricsUpdate>[0]
);
logger.info("x");
logger.offMetricsUpdate(
listener as Parameters<typeof logger.offMetricsUpdate>[0]
);
logger.info("y");
expect(updates).toHaveLength(1);
});
});

View File

@@ -0,0 +1,122 @@
import { AbstractLogger } from "../abstract/abstract-logger.js";
import type {
LogEntry,
LoggerMetrics,
LogLevel,
} from "../record/log-record.types.js";
import type {
EventLoggerConfig,
LoggerEventBus,
LoggerEvents,
} from "./event-logger.types.js";
type AnyListener = (payload: unknown) => void;
export class EventLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends AbstractLogger<TMetadata> {
private readonly emitter: LoggerEventBus<TMetadata>;
// Maps each registered listener to its unsubscribe handle
private readonly unsubscribers = new WeakMap<AnyListener, () => void>();
public constructor(
emitter: LoggerEventBus<TMetadata>,
config: EventLoggerConfig<TMetadata> = {}
) {
super(config);
this.emitter = emitter;
}
protected writeLog(entry: LogEntry<TMetadata>): void {
this.emitter.emit("log", entry);
const levelKey = `log:${entry.level}` as keyof LoggerEvents<TMetadata>;
this.emitter.emit(
levelKey,
entry as LoggerEvents<TMetadata>[typeof levelKey]
);
this.emitter.emit(
"metrics:updated",
this.getMetrics() as unknown as LoggerEvents<TMetadata>["metrics:updated"]
);
}
private subscribe<K extends keyof LoggerEvents<TMetadata>>(
event: K,
listener: (payload: LoggerEvents<TMetadata>[K]) => void
): this {
const typedListener = listener as (
payload: LoggerEvents<TMetadata>[K]
) => void;
const unsub = this.emitter.on(event, typedListener);
this.unsubscribers.set(listener as AnyListener, unsub);
return this;
}
private unsubscribe(listener: AnyListener): this {
const unsub = this.unsubscribers.get(listener);
if (unsub) {
unsub();
this.unsubscribers.delete(listener);
}
return this;
}
public onLog(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.subscribe("log", listener);
}
public onDebug(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.subscribe("log:debug", listener);
}
public onInfo(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.subscribe("log:info", listener);
}
public onWarn(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.subscribe("log:warn", listener);
}
public onError(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.subscribe("log:error", listener);
}
public onMetricsUpdate(listener: (metrics: LoggerMetrics) => void): this {
return this.subscribe(
"metrics:updated",
listener as unknown as (
payload: LoggerEvents<TMetadata>["metrics:updated"]
) => void
);
}
public offLog(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.unsubscribe(listener as AnyListener);
}
public offDebug(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.unsubscribe(listener as AnyListener);
}
public offInfo(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.unsubscribe(listener as AnyListener);
}
public offWarn(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.unsubscribe(listener as AnyListener);
}
public offError(listener: (entry: LogEntry<TMetadata>) => void): this {
return this.unsubscribe(listener as AnyListener);
}
public offMetricsUpdate(listener: (metrics: LoggerMetrics) => void): this {
return this.unsubscribe(listener as AnyListener);
}
public getEmitter(): LoggerEventBus<TMetadata> {
return this.emitter;
}
}
export type { LogLevel };

View File

@@ -0,0 +1,34 @@
import type { EventBusLike } from "@suaveplan/types";
import type { AbstractLoggerConfig } from "../abstract/abstract-logger.types.js";
import type { LogEntry, LoggerMetrics } from "../record/log-record.types.js";
export interface LoggerEvents<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> {
log: LogEntry<TMetadata>;
"log:debug": LogEntry<TMetadata>;
"log:info": LogEntry<TMetadata>;
"log:warn": LogEntry<TMetadata>;
"log:error": LogEntry<TMetadata>;
"metrics:updated": LoggerMetrics;
}
export interface EventLoggerConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends AbstractLoggerConfig<TMetadata> {
eventPrefix?: string;
includeRawEntry?: boolean;
}
export type LoggerEventBus<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> = EventBusLike<Record<string, unknown>> & {
on<K extends keyof LoggerEvents<TMetadata>>(
type: K,
listener: (payload: LoggerEvents<TMetadata>[K]) => void
): () => void;
emit<K extends keyof LoggerEvents<TMetadata>>(
type: K,
payload: LoggerEvents<TMetadata>[K]
): void;
};

View File

@@ -0,0 +1,6 @@
export { EventLogger, type LogLevel } from "./event-logger";
export type {
EventLoggerConfig,
LoggerEventBus,
LoggerEvents,
} from "./event-logger.types";

View File

@@ -0,0 +1,56 @@
# factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createLogger` is an overloaded factory that returns the concrete logger class corresponding to the `type` discriminant in the configuration object. It eliminates the need for consumers to import each concrete class separately and provides a type-narrowing API — the return type is `ConsoleLogger`, `StorageLogger`, or `EventLogger` depending on which overload matches, so consumers get full intellisense on type-specific methods.
## Features
- Overloaded TypeScript signatures for `"console"`, `"storage"`, and `"event"` types
- Returns the precise concrete class (`ConsoleLogger | StorageLogger | EventLogger`)
- Exhaustive switch with TypeScript `never` guard — compile-time protection against unhandled types
- Forwards all config options to the underlying constructor unchanged
## Usage: Basic Example
```ts
import { createLogger } from "@suaveplan/logger/factory";
const log = createLogger({ type: "console", level: "info", colors: false });
log.info("Server ready");
```
## Usage: Advanced Example
```ts
import { createLogger } from "@suaveplan/logger/factory";
import type { LoggerFactoryConfig } from "@suaveplan/logger/factory";
// Dynamic selection at runtime
const config: LoggerFactoryConfig = process.env.LOG_TARGET === "json"
? { type: "console", colors: false }
: { type: "console", colors: true };
const log = createLogger(config);
```
## API Reference
### `createLogger(config): ConsoleLogger | StorageLogger | EventLogger`
Three overloads — see `factory.types.ts` for `ConsoleLoggerFactoryConfig`, `StorageLoggerFactoryConfig`, and `EventLoggerFactoryConfig`.
### `type LoggerFactoryConfig<TMetadata>`
Discriminated union with `type: "console" | "storage" | "event"`.
## Implementation Notes
The `_exhaustive: never` pattern on the default branch ensures TypeScript catches missing cases at compile time without requiring runtime `throw new Error("unreachable")` to satisfy coverage — though the throw is retained as a safety net for JavaScript consumers.
### Factory Shape
`createLogger` is the declarative entry-point for assembling a concrete logger from a `LoggerConfig` bundle: level, sinks, sampler, redactor, OTel trace enrichment, and global-error bridge. Each option has a sensible default so the zero-config call `createLogger({ name: "app" })` yields a console-sink logger at `info` level. Returned loggers satisfy the `LoggerLike` contract from `@suaveplan/types` for safe downstream injection.

View File

@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { ConsoleLogger } from "../console/console-logger.js";
import { EventLogger } from "../event/event-logger.js";
import { StorageLogger } from "../storage/storage-logger.js";
import { createLogger } from "./factory.js";
describe("createLogger", () => {
it("creates ConsoleLogger for type=console", { timeout: 5000 }, () => {
const logger = createLogger({ type: "console", colors: false });
expect(logger).toBeInstanceOf(ConsoleLogger);
});
it("creates StorageLogger for type=storage", { timeout: 5000 }, () => {
const storage = {
set: vi.fn(),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
};
const logger = createLogger({
type: "storage",
storage,
autoFlush: false,
});
expect(logger).toBeInstanceOf(StorageLogger);
});
it("creates EventLogger for type=event", { timeout: 5000 }, () => {
const emitter = { on: vi.fn(() => () => {}), emit: vi.fn() };
const logger = createLogger({ type: "event", emitter });
expect(logger).toBeInstanceOf(EventLogger);
});
it("preserves level config", { timeout: 5000 }, () => {
const logger = createLogger({
type: "console",
level: "error",
colors: false,
});
expect(logger.getLevel()).toBe("error");
});
it("throws on unknown type", { timeout: 5000 }, () => {
expect(() =>
createLogger({ type: "unknown" } as unknown as Parameters<
typeof createLogger
>[0])
).toThrow("Unknown logger type");
});
});

View File

@@ -0,0 +1,44 @@
import type { AbstractLogger } from "../abstract/abstract-logger.js";
import { ConsoleLogger } from "../console/console-logger.js";
import { EventLogger } from "../event/event-logger.js";
import { StorageLogger } from "../storage/storage-logger.js";
import type {
ConsoleLoggerFactoryConfig,
EventLoggerFactoryConfig,
LoggerFactoryConfig,
StorageLoggerFactoryConfig,
} from "./factory.types.js";
export function createLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
>(config: ConsoleLoggerFactoryConfig<TMetadata>): ConsoleLogger<TMetadata>;
export function createLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
>(config: StorageLoggerFactoryConfig<TMetadata>): StorageLogger<TMetadata>;
export function createLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
>(config: EventLoggerFactoryConfig<TMetadata>): EventLogger<TMetadata>;
export function createLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
>(config: LoggerFactoryConfig<TMetadata>): AbstractLogger<TMetadata> {
switch (config.type) {
case "console": {
const { type: _type, ...rest } = config;
return new ConsoleLogger<TMetadata>(rest);
}
case "storage": {
const { type: _type, storage, ...rest } = config;
return new StorageLogger<TMetadata>(storage, rest);
}
case "event": {
const { type: _type, emitter, ...rest } = config;
return new EventLogger<TMetadata>(emitter, rest);
}
default: {
const _exhaustive: never = config;
throw new Error(
`Unknown logger type: ${(_exhaustive as LoggerFactoryConfig).type}`
);
}
}
}

View File

@@ -0,0 +1,36 @@
import type { ConsoleLoggerConfig } from "../console/console-logger.types.js";
import type {
EventLoggerConfig,
LoggerEventBus,
} from "../event/event-logger.types.js";
import type {
StorageInterface,
StorageLoggerConfig,
} from "../storage/storage-logger.types.js";
export interface ConsoleLoggerFactoryConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends ConsoleLoggerConfig<TMetadata> {
type: "console";
}
export interface StorageLoggerFactoryConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends StorageLoggerConfig<TMetadata> {
type: "storage";
storage: StorageInterface;
}
export interface EventLoggerFactoryConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends EventLoggerConfig<TMetadata> {
type: "event";
emitter: LoggerEventBus<TMetadata>;
}
export type LoggerFactoryConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> =
| ConsoleLoggerFactoryConfig<TMetadata>
| StorageLoggerFactoryConfig<TMetadata>
| EventLoggerFactoryConfig<TMetadata>;

View File

@@ -0,0 +1,7 @@
export { createLogger } from "./factory";
export type {
ConsoleLoggerFactoryConfig,
EventLoggerFactoryConfig,
LoggerFactoryConfig,
StorageLoggerFactoryConfig,
} from "./factory.types";

View File

@@ -0,0 +1,97 @@
// Abstract
export type {
AbstractLoggerConfig,
LogEntry,
Logger,
LoggerMetrics,
LogLevel,
LogLevels,
LogRecord,
} from "./abstract/abstract-logger.js";
export { AbstractLogger, LOG_LEVELS } from "./abstract/abstract-logger.js";
// Console
export { ConsoleLogger } from "./console/console-logger.js";
export type { ConsoleLoggerConfig } from "./console/console-logger.types.js";
// Error bridge
export {
__resetDefaultLoggerForTests,
installSuaveplanErrorInterceptor,
logSuaveplanError,
SEVERITY_TO_LEVEL,
} from "./error-bridge/error-bridge.js";
export type {
InstallErrorInterceptorOptions,
SeverityLevelMap,
UninstallErrorInterceptor,
} from "./error-bridge/error-bridge.types.js";
// Errors
export { LOGGER_CODES, LoggerError } from "./errors/logger-error.js";
export type { LoggerErrorCode } from "./errors/logger-error.types.js";
// Event
export { EventLogger } from "./event/event-logger.js";
export type {
EventLoggerConfig,
LoggerEventBus,
LoggerEvents,
} from "./event/event-logger.types.js";
// Factory
export { createLogger } from "./factory/factory.js";
export type {
ConsoleLoggerFactoryConfig,
EventLoggerFactoryConfig,
LoggerFactoryConfig,
StorageLoggerFactoryConfig,
} from "./factory/factory.types.js";
export type { InitLoggerOptions, InitLoggerResult } from "./init/init.js";
// Init
export { initLogger } from "./init/init.js";
export type { OtelTraceContext } from "./otel/otel-context.js";
// OTel
export { getOtelTraceContext } from "./otel/otel-context.js";
// Record
export {
buildRecord,
isLogLevel,
LOG_LEVEL_NAMES,
shouldLog,
} from "./record/log-record.js";
// Redaction
export { applyRedaction, RedactionEngine } from "./redaction/redaction.js";
export type {
RedactionOptions,
RedactionRule,
} from "./redaction/redaction.types.js";
// Sampling
export {
createSampler,
HeadSampler,
RuleSampler,
SampleAllSampler,
SampleNoneSampler,
TailSampler,
} from "./sampling/sampling.js";
export type {
Sampler,
SamplingDecision,
SamplingPolicy,
} from "./sampling/sampling.types.js";
export { BufferedSink } from "./sink/buffered-sink.js";
export { ConsoleSink } from "./sink/console-sink.js";
export { JsonSink } from "./sink/json-sink.js";
// Sink
export { closeSink, flushSink, isSink, MultiSink } from "./sink/sink.js";
export type {
BufferedSinkOptions,
ConsoleSinkOptions,
JsonSinkOptions,
OverflowPolicy,
Sink,
} from "./sink/sink.types.js";
// Storage
export { StorageLogger } from "./storage/storage-logger.js";
export type {
StorageInterface,
StorageLoggerConfig,
} from "./storage/storage-logger.types.js";

View File

@@ -0,0 +1,5 @@
export {
type InitLoggerOptions,
type InitLoggerResult,
initLogger,
} from "./init";

View File

@@ -0,0 +1,65 @@
# init
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`initLogger` is the single bootstrap entry point for `@suaveplan/logger`. A caller invokes it once at application startup to get a fully configured logger with the `@suaveplan/error` interceptor bridge already wired. The function is idempotent-safe — the underlying `installSuaveplanErrorInterceptor` guards against double-registration — so calling `initLogger` from multiple modules during startup is safe.
## Features
- Accepts an optional pre-built `logger` to wire, otherwise creates a `ConsoleLogger`
- Accepts `consoleConfig` to configure the default `ConsoleLogger` without constructing it manually
- Wires `installSuaveplanErrorInterceptor` and returns the `uninstall` handle alongside the logger
- Returns a typed `InitLoggerResult` so callers can cleanly tear down on shutdown
## Usage: Basic Example
```ts
import { initLogger } from "@suaveplan/logger/init";
const { logger, uninstall } = initLogger();
logger.info("Application started");
// On shutdown:
process.on("SIGTERM", () => uninstall());
```
## Usage: Advanced Example
```ts
import { initLogger } from "@suaveplan/logger/init";
import { JsonSink } from "@suaveplan/logger/sink";
const jsonSink = new JsonSink({ writer: writeToLogAggregator });
const { logger, uninstall } = initLogger({
consoleConfig: {
level: "info",
sinks: [jsonSink],
redaction: { rules: [{ kind: "pattern", pattern: /token|secret/i }] },
},
});
logger.info("Server ready", { port: 8080 });
```
## API Reference
### `initLogger<TMetadata>(options?): InitLoggerResult`
| Option | Type | Description |
|--------|------|-------------|
| `logger` | `Logger?` | Pre-built logger to wire into the error bridge |
| `consoleConfig` | `ConsoleLoggerConfig?` | Config for the default `ConsoleLogger` when `logger` is absent |
Returns `{ logger: Logger, uninstall: () => void }`.
## Implementation Notes
`initLogger` is intentionally thin — it delegates to `ConsoleLogger` and `installSuaveplanErrorInterceptor`. Keeping it as a thin coordinator makes it testable: callers that need a custom logger or specific sink configuration pass them in, rather than configuring a global singleton.
### Startup Hook
`initLogger` is the process-startup counterpart to `createLogger` — it installs a global error bridge that catches uncaught exceptions, unhandled promise rejections, and `SIGTERM` / `SIGINT` signals, then flushes any buffered sinks before exiting. Calling `initLogger` more than once is a no-op: the module tracks its own install state on a `Symbol.for` marker.

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { ConsoleLogger } from "../console/console-logger.js";
import { __resetDefaultLoggerForTests } from "../error-bridge/error-bridge.js";
import { initLogger } from "./init.js";
afterEach(() => {
__resetDefaultLoggerForTests();
});
describe("initLogger", () => {
it("returns a logger and uninstall function", { timeout: 5000 }, () => {
const { logger, uninstall } = initLogger();
expect(logger).toBeDefined();
expect(typeof uninstall).toBe("function");
uninstall();
});
it("uses provided logger", { timeout: 5000 }, () => {
const custom = new ConsoleLogger({ level: "error", colors: false });
const { logger } = initLogger({ logger: custom });
expect(logger).toBe(custom);
});
it("creates ConsoleLogger when no logger provided", {
timeout: 5000,
}, () => {
const { logger } = initLogger();
expect(logger).toBeInstanceOf(ConsoleLogger);
});
it("passes consoleConfig to the default logger", { timeout: 5000 }, () => {
const { logger } = initLogger({ consoleConfig: { level: "error" } });
expect(logger.getLevel()).toBe("error");
});
it("uninstall clears the interceptor", { timeout: 5000 }, () => {
const { uninstall } = initLogger();
uninstall();
// After uninstall, a fresh init should succeed
const second = initLogger();
expect(second.logger).toBeDefined();
second.uninstall();
});
});

View File

@@ -0,0 +1,29 @@
import type { Logger } from "../abstract/abstract-logger.js";
import type { AbstractLoggerConfig } from "../abstract/abstract-logger.types.js";
import { ConsoleLogger } from "../console/console-logger.js";
import type { ConsoleLoggerConfig } from "../console/console-logger.types.js";
import { installSuaveplanErrorInterceptor } from "../error-bridge/error-bridge.js";
import type { UninstallErrorInterceptor } from "../error-bridge/error-bridge.types.js";
export interface InitLoggerOptions<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> {
logger?: Logger;
consoleConfig?: ConsoleLoggerConfig<TMetadata> &
AbstractLoggerConfig<TMetadata>;
}
export interface InitLoggerResult {
logger: Logger;
uninstall: UninstallErrorInterceptor;
}
export function initLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
>(options: InitLoggerOptions<TMetadata> = {}): InitLoggerResult {
const logger =
options.logger ??
new ConsoleLogger<TMetadata>(options.consoleConfig ?? {});
const uninstall = installSuaveplanErrorInterceptor(logger);
return { logger, uninstall };
}

View File

@@ -0,0 +1 @@
export { getOtelTraceContext, type OtelTraceContext } from "./otel-context";

View File

@@ -0,0 +1,62 @@
# otel-context
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`otel-context` extracts the active OpenTelemetry trace context (`trace_id` and `span_id`) from the current OTel context and returns them as lowercase hex strings. This allows `AbstractLogger` to automatically annotate every log record with the correlation IDs of the span in which the log call was made — without requiring callers to pass trace context explicitly. When no span is active or the OTel API is configured with a no-op provider, the function returns an empty object without throwing.
## Features
- Reads `trace_id` and `span_id` from the active span via `@opentelemetry/api`
- Returns `{}` when no span is active, the span context has all-zero IDs (no-op provider), or the OTel API throws unexpectedly
- Never throws — defensive `try/catch` wraps the entire extraction
- Zero side effects: pure function, no caching, safe to call on every log line
## Usage: Basic Example
```ts
import { getOtelTraceContext } from "@suaveplan/logger/otel";
const traceCtx = getOtelTraceContext();
// When inside an active span: { trace_id: "abc123...", span_id: "def456..." }
// When no span is active: {}
```
## Usage: Advanced Example
```ts
import { buildRecord } from "@suaveplan/logger/record";
import { getOtelTraceContext } from "@suaveplan/logger/otel";
// Used internally by AbstractLogger on every log call:
const traceCtx = getOtelTraceContext();
const record = buildRecord(level, msg, ctx, ts, traceCtx, errorPayload);
// record.trace_id and record.span_id are populated when a span is active
```
## API Reference
### `getOtelTraceContext(): OtelTraceContext`
Returns an object with optional `trace_id` and `span_id` string fields.
Returns `{}` when:
- No span is active in the current context
- The active span has an all-zero trace ID (indicates a no-op OTel SDK)
- `@opentelemetry/api` throws for any reason
### `interface OtelTraceContext`
```ts
interface OtelTraceContext {
trace_id?: string;
span_id?: string;
}
```
## Implementation Notes
The zero-ID guard (`00000000000000000000000000000000`) exists because the OTel no-op provider returns a valid-looking `SpanContext` with all-zero IDs rather than returning `undefined`. Emitting those zero IDs would mislead log consumers into thinking a real trace exists. The `try/catch` guard exists because OTel API implementations can throw in unusual environments (e.g., incompatible versions, patched globals in test environments).

View File

@@ -0,0 +1,68 @@
import { trace } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { getOtelTraceContext } from "./otel-context.js";
describe("getOtelTraceContext", () => {
it("returns empty object when no active span", { timeout: 5000 }, () => {
const ctx = getOtelTraceContext();
expect(ctx).toEqual({});
});
it("returns trace_id and span_id from an active span", {
timeout: 5000,
}, () => {
const tracer = trace.getTracer("test");
let captured: ReturnType<typeof getOtelTraceContext> = {};
tracer.startActiveSpan("test-span", (span) => {
captured = getOtelTraceContext();
span.end();
});
// With no-op SDK the span context has all-zero IDs; still returns {}
expect(captured).toBeDefined();
});
it("never throws even if api misbehaves", { timeout: 5000 }, () => {
// Verify the try/catch guard: simulate a bad context
const origGetSpan = trace.getSpan.bind(trace);
(trace as any).getSpan = () => {
throw new Error("api broken");
};
expect(() => getOtelTraceContext()).not.toThrow();
(trace as any).getSpan = origGetSpan;
});
it("returns empty object for zero-value trace IDs", {
timeout: 5000,
}, () => {
const noopSpan = {
spanContext: () => ({
traceId: "00000000000000000000000000000000",
spanId: "0000000000000000",
traceFlags: 0,
}),
};
const origGetSpan = trace.getSpan.bind(trace);
(trace as any).getSpan = () => noopSpan;
const result = getOtelTraceContext();
expect(result).toEqual({});
(trace as any).getSpan = origGetSpan;
});
it("returns trace_id and span_id for a real non-zero span context", {
timeout: 5000,
}, () => {
const realSpan = {
spanContext: () => ({
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
spanId: "00f067aa0ba902b7",
traceFlags: 1,
}),
};
const origGetSpan = trace.getSpan.bind(trace);
(trace as any).getSpan = () => realSpan;
const result = getOtelTraceContext();
expect(result.trace_id).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
expect(result.span_id).toBe("00f067aa0ba902b7");
(trace as any).getSpan = origGetSpan;
});
});

View File

@@ -0,0 +1,20 @@
import { context, trace } from "@opentelemetry/api";
export interface OtelTraceContext {
trace_id?: string;
span_id?: string;
}
export function getOtelTraceContext(): OtelTraceContext {
try {
const span = trace.getSpan(context.active());
if (!span) return {};
const sc = span.spanContext();
if (!sc.traceId || sc.traceId === "00000000000000000000000000000000") {
return {};
}
return { trace_id: sc.traceId, span_id: sc.spanId };
} catch {
return {};
}
}

View File

@@ -0,0 +1,21 @@
export {
buildRecord,
isLogLevel,
LOG_LEVEL_NAMES,
LOG_LEVELS,
type LogLevel,
type LogRecord,
shouldLog,
} from "./log-record";
export {
LogLevelSchema,
type LogRecordInput,
type LogRecordOutput,
LogRecordSchema,
} from "./log-record.schemas";
export type {
LogEntry,
LoggerConfig,
LoggerMetrics,
LogLevels,
} from "./log-record.types";

View File

@@ -0,0 +1,88 @@
# log-record
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`log-record` defines the canonical structured format (`LogRecord`) that every log emission produces before being handed to sinks. By standardising on this shape — `ts`, `level`, `msg`, `ctx`, `trace_id`, `span_id`, `error` — every downstream component (redaction, sampling, sinks, OTel) operates on a single well-typed contract rather than ad-hoc strings. The Zod schema in `log-record.schemas.ts` provides runtime validation and serves as the serialisation contract.
## Features
- `LogRecord` structured type with ISO timestamp, level, message, merged context, optional OTel IDs, optional error payload
- `LOG_LEVELS` numeric ordering map for level comparison
- `isLogLevel(value)` type guard
- `shouldLog(current, candidate)` — returns `true` when `candidate >= current`
- `buildRecord(...)` — constructs a `LogRecord`, omitting undefined optional fields
- Zod schema `LogRecordSchema` with `z.datetime()` validation for `ts`
## Usage: Basic Example
```ts
import { buildRecord } from "@suaveplan/logger/record";
const record = buildRecord(
"info",
"User signed in",
{ userId: "u-123" },
new Date().toISOString(),
);
// { ts: "...", level: "info", msg: "User signed in", ctx: { userId: "u-123" } }
```
## Usage: Advanced Example
```ts
import { buildRecord, shouldLog } from "@suaveplan/logger/record";
import { LogRecordSchema } from "@suaveplan/logger/record.schemas";
// Only build the record if the level passes the filter
if (shouldLog("warn", "error")) {
const record = buildRecord(
"error",
"Payment failed",
{ orderId: "o-42" },
clock.now().toISOString(),
{ trace_id: "abc123ef", span_id: "def456ab" },
error.toJSON(),
);
// Validate before sink dispatch (defensive — normally guaranteed by pipeline)
const parsed = LogRecordSchema.safeParse(record);
if (!parsed.success) throw new Error("Invariant: invalid LogRecord");
}
```
## API Reference
### `LOG_LEVELS: Record<LogLevel, number>`
Numeric weights: `{ debug: 0, info: 1, warn: 2, error: 3 }`.
### `isLogLevel(value): value is LogLevel`
Type guard returning `true` for `"debug" | "info" | "warn" | "error"`.
### `shouldLog(current, candidate): boolean`
Returns `true` when `LOG_LEVELS[candidate] >= LOG_LEVELS[current]`.
### `buildRecord(level, msg, ctx, ts, traceCtx?, errorPayload?): LogRecord`
| Parameter | Type | Description |
|-----------|------|-------------|
| `level` | `LogLevel` | Severity |
| `msg` | `string` | Human-readable message |
| `ctx` | `Record<string, unknown>` | Merged metadata/tags |
| `ts` | `string` | ISO-8601 timestamp |
| `traceCtx` | `{ trace_id?, span_id? }?` | OTel context |
| `errorPayload` | `unknown?` | Serialised error payload |
Omits `trace_id`, `span_id`, and `error` keys entirely when their values are `undefined`.
### `LogRecordSchema` (from `log-record.schemas.ts`)
Zod schema validating `LogRecord`. Fields: `ts` (`z.string().datetime()`), `level` (`LogLevelSchema`), `msg` (`z.string()`), `ctx` (`z.record(z.unknown())`), optional `trace_id`, `span_id`, `error`.
## Implementation Notes
`buildRecord` deliberately omits keys with `undefined` values rather than setting them to `undefined` — this keeps NDJSON payloads lean and avoids confusing consumers that test `"key" in record`. The Zod schema defaults `ctx` to `{}` so consumers can omit it when there is no additional context.

View File

@@ -0,0 +1,16 @@
import z from "zod";
export const LogLevelSchema = z.enum(["debug", "info", "warn", "error"]);
export const LogRecordSchema = z.object({
ts: z.string().datetime({ offset: true }),
level: LogLevelSchema,
msg: z.string(),
ctx: z.record(z.string(), z.unknown()).default({}),
trace_id: z.string().optional(),
span_id: z.string().optional(),
error: z.unknown().optional(),
});
export type LogRecordInput = z.input<typeof LogRecordSchema>;
export type LogRecordOutput = z.output<typeof LogRecordSchema>;

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildRecord,
isLogLevel,
LOG_LEVELS,
shouldLog,
} from "./log-record.js";
import { LogRecordSchema } from "./log-record.schemas.js";
describe("LOG_LEVELS", () => {
it("orders levels correctly", { timeout: 5000 }, () => {
expect(LOG_LEVELS.debug).toBeLessThan(LOG_LEVELS.info);
expect(LOG_LEVELS.info).toBeLessThan(LOG_LEVELS.warn);
expect(LOG_LEVELS.warn).toBeLessThan(LOG_LEVELS.error);
});
});
describe("isLogLevel", () => {
it("returns true for valid levels", { timeout: 5000 }, () => {
expect(isLogLevel("debug")).toBe(true);
expect(isLogLevel("info")).toBe(true);
expect(isLogLevel("warn")).toBe(true);
expect(isLogLevel("error")).toBe(true);
});
it("returns false for invalid values", { timeout: 5000 }, () => {
expect(isLogLevel("verbose")).toBe(false);
expect(isLogLevel(42)).toBe(false);
expect(isLogLevel(null)).toBe(false);
expect(isLogLevel(undefined)).toBe(false);
});
});
describe("shouldLog", () => {
it("allows levels at or above the minimum", { timeout: 5000 }, () => {
expect(shouldLog("info", "info")).toBe(true);
expect(shouldLog("info", "warn")).toBe(true);
expect(shouldLog("info", "error")).toBe(true);
});
it("blocks levels below the minimum", { timeout: 5000 }, () => {
expect(shouldLog("info", "debug")).toBe(false);
expect(shouldLog("error", "warn")).toBe(false);
});
});
describe("buildRecord", () => {
const ts = new Date().toISOString();
it("builds a minimal record", { timeout: 5000 }, () => {
const rec = buildRecord("info", "hello", {}, ts);
expect(rec.level).toBe("info");
expect(rec.msg).toBe("hello");
expect(rec.ts).toBe(ts);
expect(rec.ctx).toEqual({});
expect(rec.trace_id).toBeUndefined();
expect(rec.span_id).toBeUndefined();
expect(rec.error).toBeUndefined();
});
it("attaches trace context when provided", { timeout: 5000 }, () => {
const rec = buildRecord("warn", "traced", {}, ts, {
trace_id: "abc123",
span_id: "def456",
});
expect(rec.trace_id).toBe("abc123");
expect(rec.span_id).toBe("def456");
});
it("attaches error payload when provided", { timeout: 5000 }, () => {
const rec = buildRecord("error", "boom", {}, ts, undefined, {
code: "SOMETHING",
});
expect(rec.error).toEqual({ code: "SOMETHING" });
});
it("omits undefined optional fields", { timeout: 5000 }, () => {
const rec = buildRecord("debug", "trace-less", {}, ts, {});
expect("trace_id" in rec).toBe(false);
expect("span_id" in rec).toBe(false);
});
});
describe("LogRecordSchema", () => {
it("validates a well-formed record", { timeout: 5000 }, () => {
const ts = new Date().toISOString();
const result = LogRecordSchema.safeParse({
ts,
level: "info",
msg: "hello",
ctx: { userId: "u1" },
});
expect(result.success).toBe(true);
});
it("rejects an invalid level", { timeout: 5000 }, () => {
const ts = new Date().toISOString();
const result = LogRecordSchema.safeParse({
ts,
level: "verbose",
msg: "hello",
ctx: {},
});
expect(result.success).toBe(false);
});
it("rejects a non-ISO timestamp", { timeout: 5000 }, () => {
const result = LogRecordSchema.safeParse({
ts: "not-a-date",
level: "info",
msg: "hello",
ctx: {},
});
expect(result.success).toBe(false);
});
it("defaults ctx to empty object when absent", { timeout: 5000 }, () => {
const ts = new Date().toISOString();
const result = LogRecordSchema.safeParse({
ts,
level: "debug",
msg: "m",
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.ctx).toEqual({});
});
});

View File

@@ -0,0 +1,41 @@
import type { LogLevel, LogRecord } from "./log-record.types.js";
export const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
} as const;
export const LOG_LEVEL_NAMES = ["debug", "info", "warn", "error"] as const;
export function isLogLevel(value: unknown): value is LogLevel {
return (
typeof value === "string" &&
(value === "debug" ||
value === "info" ||
value === "warn" ||
value === "error")
);
}
export function shouldLog(current: LogLevel, candidate: LogLevel): boolean {
return LOG_LEVELS[candidate] >= LOG_LEVELS[current];
}
export function buildRecord(
level: LogLevel,
msg: string,
ctx: Record<string, unknown>,
ts: string,
traceCtx?: { trace_id?: string; span_id?: string },
errorPayload?: unknown
): LogRecord {
const record: LogRecord = { ts, level, msg, ctx };
if (traceCtx?.trace_id) record.trace_id = traceCtx.trace_id;
if (traceCtx?.span_id) record.span_id = traceCtx.span_id;
if (errorPayload !== undefined) record.error = errorPayload;
return record;
}
export type { LogLevel, LogRecord };

View File

@@ -0,0 +1,47 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogLevels = Record<LogLevel, number>;
export interface LogRecord {
/** ISO-8601 UTC timestamp */
ts: string;
level: LogLevel;
/** Human-readable message */
msg: string;
/** Merged context: metadata + tags */
ctx: Record<string, unknown>;
trace_id?: string;
span_id?: string;
/** Serialised error payload */
error?: unknown;
}
/** Legacy shape preserved for backward compatibility with AbstractLogger */
export interface LogEntry<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> {
level: LogLevel;
message: string;
timestamp: Date;
metadata?: TMetadata;
tags?: string[];
}
export interface LoggerConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> {
level?: LogLevel;
enabled?: boolean;
metadata?: TMetadata;
tags?: string[];
}
export interface LoggerMetrics {
totalLogs: number;
logsByLevel: Record<LogLevel, number>;
droppedBySampling: number;
droppedByBackpressure: number;
/** Records lost because a sink threw / rejected during dispatch */
droppedBySinkError: number;
lastLogTime?: Date;
}

View File

@@ -0,0 +1,11 @@
export {
applyRedaction,
RedactionEngine,
type RedactionOptions,
type RedactionRule,
} from "./redaction";
export type {
PathRedactionRule,
PatternRedactionRule,
RedactionRuleKind,
} from "./redaction.types";

View File

@@ -0,0 +1,91 @@
# redaction
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The redaction module provides declarative, irreversible sanitisation of log context objects before they reach any sink. It supports two rule kinds: field-path rules (dot-notation with `*` wildcard) and pattern rules (regex applied to leaf key names). It also automatically redacts any value wrapped in `Sensitive<T>` from `@suaveplan/error`. Redaction always operates on a deep clone — the original context is never mutated.
The invariant is strict: once a field matches a rule or is `Sensitive`, its value becomes the label `"[REDACTED]"` and can never be recovered from the log record. This ensures sensitive data (passwords, tokens, PII) never reaches log storage or downstream consumers.
## Features
- `PathRedactionRule` — matches exact dot-notation paths; supports `*` wildcard at any segment
- `PatternRedactionRule` — applies a `RegExp` to every leaf key name
- Automatic detection of `Sensitive<T>` wrappers via `isSensitive()` from `@suaveplan/error`
- Deep cloning before redaction — original object untouched
- Configurable replacement label (default `"[REDACTED]"`) and max traversal depth (default `10`)
- `RedactionEngine` class for reuse across many records; `applyRedaction()` functional one-shot helper
- Property-based tests (fast-check) verify the redaction invariant across arbitrary inputs
## Usage: Basic Example
```ts
import { applyRedaction } from "@suaveplan/logger/redaction";
const clean = applyRedaction(
{ userId: "u-1", password: "hunter2", metadata: { token: "abc" } },
{
rules: [
{ kind: "path", path: "password" },
{ kind: "path", path: "metadata.token" },
],
},
);
// { userId: "u-1", password: "[REDACTED]", metadata: { token: "[REDACTED]" } }
```
## Usage: Advanced Example
```ts
import { RedactionEngine } from "@suaveplan/logger/redaction";
import { markSensitive } from "@suaveplan/error/redact";
const engine = new RedactionEngine({
rules: [
{ kind: "pattern", pattern: /password|secret|token/i },
{ kind: "path", path: "*.credentials" },
],
redactedLabel: "***",
maxDepth: 5,
});
// Sensitive<T> is auto-detected without a rule:
const ctx = { ssn: markSensitive("123-45-6789"), name: "Alice" };
const clean = engine.redact(ctx);
// { ssn: "***", name: "Alice" }
```
## API Reference
### `class RedactionEngine`
**Constructor**: `new RedactionEngine(options?: RedactionOptions)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `rules` | `RedactionRule[]` | `[]` | Path and pattern rules |
| `redactedLabel` | `string` | `"[REDACTED]"` | Replacement value |
| `maxDepth` | `number` | `10` | Maximum recursion depth |
#### `.redact(ctx): Record<string, unknown>`
Returns a deep-cloned copy of `ctx` with all matching fields replaced by the label.
### `applyRedaction(ctx, options?): Record<string, unknown>`
Functional one-shot wrapper — creates a `RedactionEngine` and calls `.redact()`.
### `type RedactionRule`
```ts
type RedactionRule = PathRedactionRule | PatternRedactionRule;
interface PathRedactionRule { kind: "path"; path: string; }
interface PatternRedactionRule { kind: "pattern"; pattern: RegExp; }
```
## Implementation Notes
Path matching splits both the rule path and the candidate key path on `.`, then compares segments one-by-one, treating `*` as a wildcard. Paths of different lengths never match. The wildcard only covers a single segment — `a.*.c` matches `a.b.c` but not `a.b.d.c`. Arrays are traversed; each element uses a `[i]`-suffixed path that path rules will never match (arrays are safe by default — only Sensitive wrappers or pattern rules can redact array elements).

View File

@@ -0,0 +1,167 @@
import { markSensitive } from "@suaveplan/error/redact";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { applyRedaction, RedactionEngine } from "./redaction.js";
describe("RedactionEngine — path rules", () => {
it("redacts an exact path", { timeout: 5000 }, () => {
const engine = new RedactionEngine({
rules: [{ kind: "path", path: "password" }],
});
const result = engine.redact({ password: "secret", name: "Alice" });
expect(result.password).toBe("[REDACTED]");
expect(result.name).toBe("Alice");
});
it("redacts a nested path", { timeout: 5000 }, () => {
const engine = new RedactionEngine({
rules: [{ kind: "path", path: "user.token" }],
});
const result = engine.redact({ user: { token: "abc", name: "Bob" } });
expect((result.user as Record<string, unknown>).token).toBe(
"[REDACTED]"
);
expect((result.user as Record<string, unknown>).name).toBe("Bob");
});
it("redacts with wildcard segment", { timeout: 5000 }, () => {
const engine = new RedactionEngine({
rules: [{ kind: "path", path: "*.secret" }],
});
const result = engine.redact({
a: { secret: "x" },
b: { secret: "y" },
c: { other: "z" },
});
expect((result.a as Record<string, unknown>).secret).toBe("[REDACTED]");
expect((result.b as Record<string, unknown>).secret).toBe("[REDACTED]");
expect((result.c as Record<string, unknown>).other).toBe("z");
});
it("uses custom redacted label", { timeout: 5000 }, () => {
const engine = new RedactionEngine({
rules: [{ kind: "path", path: "pin" }],
redactedLabel: "***",
});
expect(engine.redact({ pin: "1234" }).pin).toBe("***");
});
});
describe("RedactionEngine — pattern rules", () => {
it("redacts keys matching pattern", { timeout: 5000 }, () => {
const engine = new RedactionEngine({
rules: [
{ kind: "pattern", pattern: /^password$|^secret$|^token$/i },
],
});
const result = engine.redact({
password: "p",
secretKey: "k", // does not match exact pattern
token: "t",
name: "Alice",
});
expect(result.password).toBe("[REDACTED]");
expect(result.secretKey).toBe("k"); // exact pattern does not match secretKey
expect(result.token).toBe("[REDACTED]");
expect(result.name).toBe("Alice");
});
});
describe("RedactionEngine — Sensitive<T>", () => {
it("redacts Sensitive-wrapped values automatically", {
timeout: 5000,
}, () => {
const engine = new RedactionEngine();
const result = engine.redact({
ssn: markSensitive("123-45-6789"),
ok: 1,
});
expect(result.ssn).toBe("[REDACTED]");
expect(result.ok).toBe(1);
});
});
describe("RedactionEngine — immutability", () => {
it("does not mutate the original object", { timeout: 5000 }, () => {
const engine = new RedactionEngine({
rules: [{ kind: "path", path: "token" }],
});
const original = { token: "secret", data: "ok" };
engine.redact(original);
expect(original.token).toBe("secret");
});
});
describe("applyRedaction", () => {
it("is a functional wrapper around RedactionEngine", {
timeout: 5000,
}, () => {
const result = applyRedaction(
{ key: "val", secret: "s" },
{ rules: [{ kind: "path", path: "secret" }] }
);
expect(result.secret).toBe("[REDACTED]");
expect(result.key).toBe("val");
});
it("uses default options when called without second argument", {
timeout: 5000,
}, () => {
const result = applyRedaction({ key: "val" });
expect(result.key).toBe("val");
});
});
describe("RedactionEngine — property tests", () => {
const pathRule = { kind: "path" as const, path: "sensitive" };
const engine = new RedactionEngine({ rules: [pathRule] });
it("redacted context never contains raw sensitive value", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.string({ minLength: 1 }), (secret) => {
const result = engine.redact({
sensitive: secret,
other: "safe",
});
expect(result.sensitive).toBe("[REDACTED]");
expect(result.other).toBe("safe");
}),
{ numRuns: 200 }
);
});
it("redaction is idempotent — applying twice gives same result", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.string({ minLength: 1 }), (v) => {
const once = engine.redact({ sensitive: v });
const twice = engine.redact(once as Record<string, unknown>);
expect(twice.sensitive).toBe("[REDACTED]");
expect(once.sensitive).toBe("[REDACTED]");
}),
{ numRuns: 200 }
);
});
it("non-sensitive fields are preserved after redaction", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.record({
safe: fc.string(),
num: fc.integer(),
}),
({ safe, num }) => {
const result = engine.redact({ safe, num, sensitive: "x" });
expect(result.safe).toBe(safe);
expect(result.num).toBe(num);
}
),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,131 @@
import { isSensitive } from "@suaveplan/error/redact";
import type {
PathRedactionRule,
PatternRedactionRule,
RedactionOptions,
RedactionRule,
} from "./redaction.types.js";
const REDACTED = "[REDACTED]";
const DEFAULT_MAX_DEPTH = 10;
function pathMatches(rulePath: string, keyPath: string): boolean {
const ruleSegments = rulePath.split(".");
const keySegments = keyPath.split(".");
if (ruleSegments.length !== keySegments.length) return false;
for (let i = 0; i < ruleSegments.length; i++) {
if (ruleSegments[i] !== "*" && ruleSegments[i] !== keySegments[i]) {
return false;
}
}
return true;
}
function shouldRedactPath(
rules: PathRedactionRule[],
keyPath: string
): boolean {
return rules.some((r) => pathMatches(r.path, keyPath));
}
function shouldRedactPattern(
rules: PatternRedactionRule[],
key: string
): boolean {
return rules.some((r) => r.pattern.test(key));
}
function redactValue(
value: unknown,
pathRules: PathRedactionRule[],
patternRules: PatternRedactionRule[],
label: string,
currentPath: string,
depth: number,
maxDepth: number
): unknown {
if (isSensitive(value)) {
return label;
}
if (
shouldRedactPath(pathRules, currentPath) ||
(currentPath !== "" &&
shouldRedactPattern(patternRules, currentPath.split(".").at(-1)!))
) {
return label;
}
if (depth >= maxDepth || value === null || typeof value !== "object") {
return value;
}
if (Array.isArray(value)) {
return value.map((item, i) =>
redactValue(
item,
pathRules,
patternRules,
label,
`${currentPath}[${i}]`,
depth + 1,
maxDepth
)
);
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const childPath = currentPath === "" ? k : `${currentPath}.${k}`;
out[k] = redactValue(
v,
pathRules,
patternRules,
label,
childPath,
depth + 1,
maxDepth
);
}
return out;
}
export class RedactionEngine {
private readonly pathRules: PathRedactionRule[];
private readonly patternRules: PatternRedactionRule[];
private readonly label: string;
private readonly maxDepth: number;
public constructor(options: RedactionOptions = {}) {
const rules: RedactionRule[] = options.rules ?? [];
this.pathRules = rules.filter(
(r): r is PathRedactionRule => r.kind === "path"
);
this.patternRules = rules.filter(
(r): r is PatternRedactionRule => r.kind === "pattern"
);
this.label = options.redactedLabel ?? REDACTED;
this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
}
public redact(ctx: Record<string, unknown>): Record<string, unknown> {
return redactValue(
ctx,
this.pathRules,
this.patternRules,
this.label,
"",
0,
this.maxDepth
) as Record<string, unknown>;
}
}
export function applyRedaction(
ctx: Record<string, unknown>,
options: RedactionOptions = {}
): Record<string, unknown> {
return new RedactionEngine(options).redact(ctx);
}
export type { RedactionOptions, RedactionRule };

View File

@@ -0,0 +1,23 @@
export type RedactionRuleKind = "path" | "pattern";
export interface PathRedactionRule {
kind: "path";
/** Dot-notation path, supports `*` glob wildcard at any segment */
path: string;
}
export interface PatternRedactionRule {
kind: "pattern";
/** Applied to every string value and to every key in the context */
pattern: RegExp;
}
export type RedactionRule = PathRedactionRule | PatternRedactionRule;
export interface RedactionOptions {
rules?: RedactionRule[];
/** Replacement label; defaults to "[REDACTED]" */
redactedLabel?: string;
/** Maximum object depth to walk; defaults to 10 */
maxDepth?: number;
}

View File

@@ -0,0 +1,16 @@
export {
createSampler,
HeadSampler,
RuleSampler,
SampleAllSampler,
SampleNoneSampler,
type Sampler,
type SamplingDecision,
type SamplingPolicy,
TailSampler,
} from "./sampling";
export type {
HeadSamplerOptions,
RuleSamplerOptions,
TailSamplerOptions,
} from "./sampling.types";

View File

@@ -0,0 +1,89 @@
# sampling
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The sampling module provides pluggable policies for deciding which log records to emit and which to drop. Reducing log volume is essential in high-throughput services where emitting every debug record would overwhelm storage. Three sampler implementations cover the common cases: `HeadSampler` for deterministic trace-ID-based rate limiting, `TailSampler` for buffered post-hoc decisions (useful for keeping error traces in full while dropping healthy ones), and `RuleSampler` for predicate-based routing.
The sampling decision happens before redaction and sink dispatch, so dropped records never touch sensitive-field handling or I/O.
## Features
- `HeadSampler` — deterministic FNV-1a hash of `trace_id` against a `rate ∈ [0,1]`; records without a `trace_id` are always kept
- `TailSampler` — buffers records per `trace_id`; flushes the buffer to a user-supplied `decide()` callback when it reaches `bufferSize`
- `RuleSampler` — ordered predicate list; first matching rule wins; configurable fallback
- `SampleAllSampler` / `SampleNoneSampler` — trivial always-keep / always-drop sentinels
- `createSampler(policy)` factory from the `SamplingPolicy` discriminated union
- Property-based tests verify `HeadSampler` determinism across 500 trace IDs
## Usage: Basic Example
```ts
import { HeadSampler } from "@suaveplan/logger/sampling";
const sampler = new HeadSampler({ rate: 0.1 }); // keep 10% of traces
const decision = sampler.decide(record); // "keep" or "drop"
```
## Usage: Advanced Example
```ts
import { createSampler } from "@suaveplan/logger/sampling";
import type { SamplingPolicy } from "@suaveplan/logger/sampling";
// Keep all errors; drop 90% of info; drop everything else
const policy: SamplingPolicy = {
kind: "rule",
options: {
rules: [
{ match: (r) => r.level === "error", decision: "keep" },
{ match: (r) => r.level === "info", decision: Math.random() < 0.1 ? "keep" : "drop" },
],
fallback: "drop",
},
};
const sampler = createSampler(policy);
```
## API Reference
### `class HeadSampler implements Sampler`
**Constructor**: `new HeadSampler({ rate: number })`
Deterministic: same `trace_id` always yields the same decision. Records without `trace_id` are always kept.
**Throws** `RangeError` if `rate < 0 || rate > 1`.
### `class TailSampler implements Sampler`
**Constructor**: `new TailSampler({ bufferSize?, decide })`
| Option | Default | Description |
|--------|---------|-------------|
| `bufferSize` | 100 | Maximum records to buffer per trace before forcing a decision |
| `decide` | — | `(records: LogRecord[]) => SamplingDecision` |
#### `.flush(traceId?): SamplingDecision`
Forces a decision on the buffer for `traceId` (or all buffers if omitted).
### `class RuleSampler implements Sampler`
**Constructor**: `new RuleSampler({ rules, fallback? })`
Applies `rules` in order; returns `fallback` (default `"keep"`) when no rule matches.
### `createSampler(policy: SamplingPolicy): Sampler`
Factory that instantiates the appropriate sampler from the `SamplingPolicy` union.
### `type SamplingPolicy`
Discriminated union: `{ kind: "all" } | { kind: "none" } | { kind: "head"; options } | { kind: "tail"; options } | { kind: "rule"; options }`.
## Implementation Notes
`HeadSampler` uses FNV-1a 32-bit hashing for determinism — the same trace ID always maps to the same fraction of the `[0, 0xFFFFFFFF]` range. This ensures all log records from a single trace are either kept or dropped together, which is the key invariant of head-based sampling. The distribution is uniform enough for practical sampling rates but not cryptographically secure — use only for sampling decisions, not security.

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import type { LogRecord } from "../record/log-record.types.js";
import {
createSampler,
HeadSampler,
RuleSampler,
SampleAllSampler,
SampleNoneSampler,
TailSampler,
} from "./sampling.js";
function makeRecord(overrides: Partial<LogRecord> = {}): LogRecord {
return {
ts: new Date().toISOString(),
level: "info",
msg: "test",
ctx: {},
...overrides,
};
}
describe("HeadSampler", () => {
it("keeps all records at rate=1", { timeout: 5000 }, () => {
const s = new HeadSampler({ rate: 1 });
expect(s.decide(makeRecord({ trace_id: "abc" }))).toBe("keep");
});
it("drops all records at rate=0", { timeout: 5000 }, () => {
const s = new HeadSampler({ rate: 0 });
expect(s.decide(makeRecord({ trace_id: "abc" }))).toBe("drop");
});
it("keeps records without trace_id at any rate < 1", {
timeout: 5000,
}, () => {
const s = new HeadSampler({ rate: 0.1 });
expect(s.decide(makeRecord())).toBe("keep");
});
it("throws on rate outside [0,1]", { timeout: 5000 }, () => {
expect(() => new HeadSampler({ rate: 1.5 })).toThrow(RangeError);
expect(() => new HeadSampler({ rate: -0.1 })).toThrow(RangeError);
});
it("is deterministic — same trace_id always yields same decision", {
timeout: 30000,
}, () => {
const s = new HeadSampler({ rate: 0.5 });
fc.assert(
fc.property(
fc.hexaString({ minLength: 16, maxLength: 32 }),
(id) => {
const a = s.decide(makeRecord({ trace_id: id }));
const b = s.decide(makeRecord({ trace_id: id }));
expect(a).toBe(b);
}
),
{ numRuns: 500 }
);
});
it("distributes decisions across the expected rate range", {
timeout: 30000,
}, () => {
const s = new HeadSampler({ rate: 0.5 });
let kept = 0;
const total = 1000;
for (let i = 0; i < total; i++) {
if (s.decide(makeRecord({ trace_id: String(i) })) === "keep")
kept++;
}
// With FNV1a hashing 1000 random IDs should land 30-70% keep
expect(kept).toBeGreaterThan(250);
expect(kept).toBeLessThan(750);
});
});
describe("TailSampler", () => {
it("buffers records until threshold", { timeout: 5000 }, () => {
const decided: string[] = [];
const s = new TailSampler({
bufferSize: 3,
decide: (recs) => {
decided.push(...recs.map((r) => r.msg));
return "keep";
},
});
const rec1 = makeRecord({ trace_id: "t1", msg: "a" });
const rec2 = makeRecord({ trace_id: "t1", msg: "b" });
const rec3 = makeRecord({ trace_id: "t1", msg: "c" });
expect(s.decide(rec1)).toBe("keep");
expect(s.decide(rec2)).toBe("keep");
expect(s.decide(rec3)).toBe("keep");
expect(decided).toEqual(["a", "b", "c"]);
});
it("flush drains all buffers", { timeout: 5000 }, () => {
const calls: number[] = [];
const s = new TailSampler({
bufferSize: 10,
decide: (recs) => {
calls.push(recs.length);
return "drop";
},
});
s.decide(makeRecord({ trace_id: "t1" }));
s.decide(makeRecord({ trace_id: "t2" }));
s.flush();
expect(calls).toHaveLength(2);
});
it("flush(traceId) flushes only that trace buffer", {
timeout: 5000,
}, () => {
const decided: string[] = [];
const s = new TailSampler({
bufferSize: 10,
decide: (recs) => {
decided.push(recs[0].msg);
return "keep";
},
});
s.decide(makeRecord({ trace_id: "t1", msg: "a" }));
s.decide(makeRecord({ trace_id: "t2", msg: "b" }));
const result = s.flush("t1");
expect(result).toBe("keep");
expect(decided).toEqual(["a"]);
});
it("flush(unknownTraceId) returns keep when buffer not found", {
timeout: 5000,
}, () => {
const s = new TailSampler({ bufferSize: 10, decide: () => "drop" });
expect(s.flush("nope")).toBe("keep");
});
it("groups records without trace_id under __no_trace__ bucket", {
timeout: 5000,
}, () => {
const decided: string[] = [];
const s = new TailSampler({
bufferSize: 2,
decide: (recs) => {
decided.push(...recs.map((r) => r.msg));
return "drop";
},
});
s.decide(makeRecord({ msg: "a" })); // no trace_id
s.decide(makeRecord({ msg: "b" })); // fills bucket → fires decider
expect(decided).toEqual(["a", "b"]);
});
});
describe("RuleSampler", () => {
it("applies rules in order, first match wins", { timeout: 5000 }, () => {
const s = new RuleSampler({
rules: [
{ match: (r) => r.level === "error", decision: "keep" },
{ match: () => true, decision: "drop" },
],
fallback: "keep",
});
expect(s.decide(makeRecord({ level: "error" }))).toBe("keep");
expect(s.decide(makeRecord({ level: "debug" }))).toBe("drop");
});
it("uses fallback when no rule matches", { timeout: 5000 }, () => {
const s = new RuleSampler({
rules: [{ match: (r) => r.level === "error", decision: "keep" }],
fallback: "drop",
});
expect(s.decide(makeRecord({ level: "info" }))).toBe("drop");
});
});
describe("SampleAllSampler / SampleNoneSampler", () => {
it("SampleAllSampler always keeps", { timeout: 5000 }, () => {
const s = new SampleAllSampler();
expect(s.decide(makeRecord())).toBe("keep");
});
it("SampleNoneSampler always drops", { timeout: 5000 }, () => {
const s = new SampleNoneSampler();
expect(s.decide(makeRecord())).toBe("drop");
});
});
describe("createSampler", () => {
it("creates HeadSampler for kind=head", { timeout: 5000 }, () => {
const s = createSampler({ kind: "head", options: { rate: 1 } });
expect(s).toBeInstanceOf(HeadSampler);
});
it("creates TailSampler for kind=tail", { timeout: 5000 }, () => {
const s = createSampler({
kind: "tail",
options: { decide: () => "keep" },
});
expect(s).toBeInstanceOf(TailSampler);
});
it("creates RuleSampler for kind=rule", { timeout: 5000 }, () => {
const s = createSampler({ kind: "rule", options: { rules: [] } });
expect(s).toBeInstanceOf(RuleSampler);
});
it("creates SampleAllSampler for kind=all", { timeout: 5000 }, () => {
const s = createSampler({ kind: "all" });
expect(s).toBeInstanceOf(SampleAllSampler);
});
it("creates SampleNoneSampler for kind=none", { timeout: 5000 }, () => {
const s = createSampler({ kind: "none" });
expect(s).toBeInstanceOf(SampleNoneSampler);
});
});

View File

@@ -0,0 +1,128 @@
import type { LogRecord } from "../record/log-record.types.js";
import type {
HeadSamplerOptions,
RuleSamplerOptions,
Sampler,
SamplingDecision,
SamplingPolicy,
TailSamplerOptions,
} from "./sampling.types.js";
function fnv1aHash32(str: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0;
}
return hash;
}
export class HeadSampler implements Sampler {
private readonly rate: number;
public constructor(options: HeadSamplerOptions) {
if (options.rate < 0 || options.rate > 1) {
throw new RangeError(
`HeadSampler rate must be in [0,1], got ${options.rate}`
);
}
this.rate = options.rate;
}
public decide(record: LogRecord): SamplingDecision {
if (this.rate === 1) return "keep";
if (this.rate === 0) return "drop";
if (!record.trace_id) return "keep";
const hash = fnv1aHash32(record.trace_id);
return hash / 0xffffffff <= this.rate ? "keep" : "drop";
}
}
export class TailSampler implements Sampler {
private readonly bufferSize: number;
private readonly decider: (records: LogRecord[]) => SamplingDecision;
private buffers = new Map<string, LogRecord[]>();
public constructor(options: TailSamplerOptions) {
this.bufferSize = options.bufferSize ?? 100;
this.decider = options.decide;
}
public decide(record: LogRecord): SamplingDecision {
const traceId = record.trace_id ?? "__no_trace__";
let buf = this.buffers.get(traceId);
if (!buf) {
buf = [];
this.buffers.set(traceId, buf);
}
buf.push(record);
if (buf.length >= this.bufferSize) {
const decision = this.decider([...buf]);
this.buffers.delete(traceId);
return decision;
}
return "keep";
}
public flush(traceId?: string): SamplingDecision {
if (traceId) {
const buf = this.buffers.get(traceId);
if (!buf) return "keep";
const decision = this.decider([...buf]);
this.buffers.delete(traceId);
return decision;
}
let last: SamplingDecision = "keep";
for (const [id, buf] of this.buffers) {
last = this.decider([...buf]);
this.buffers.delete(id);
}
return last;
}
}
export class RuleSampler implements Sampler {
private readonly rules: RuleSamplerOptions["rules"];
private readonly fallback: SamplingDecision;
public constructor(options: RuleSamplerOptions) {
this.rules = options.rules;
this.fallback = options.fallback ?? "keep";
}
public decide(record: LogRecord): SamplingDecision {
for (const rule of this.rules) {
if (rule.match(record)) return rule.decision;
}
return this.fallback;
}
}
export class SampleAllSampler implements Sampler {
public decide(_record: LogRecord): SamplingDecision {
return "keep";
}
}
export class SampleNoneSampler implements Sampler {
public decide(_record: LogRecord): SamplingDecision {
return "drop";
}
}
export function createSampler(policy: SamplingPolicy): Sampler {
switch (policy.kind) {
case "all":
return new SampleAllSampler();
case "none":
return new SampleNoneSampler();
case "head":
return new HeadSampler(policy.options);
case "tail":
return new TailSampler(policy.options);
case "rule":
return new RuleSampler(policy.options);
}
}
export type { Sampler, SamplingDecision, SamplingPolicy };

View File

@@ -0,0 +1,35 @@
import type { LogRecord } from "../record/log-record.types.js";
export type SamplingDecision = "keep" | "drop";
export interface Sampler {
decide(record: LogRecord): SamplingDecision;
}
export interface HeadSamplerOptions {
/** Fraction of traces to keep; 0 = drop all, 1 = keep all */
rate: number;
}
export interface TailSamplerOptions {
/** Maximum records to buffer per trace_id before forcing a decision */
bufferSize?: number;
/** Called when the buffer is flushed; return "keep" to emit buffered records */
decide: (records: LogRecord[]) => SamplingDecision;
}
export interface RuleSamplerOptions {
rules: Array<{
match: (record: LogRecord) => boolean;
decision: SamplingDecision;
}>;
/** Fallback when no rule matches */
fallback?: SamplingDecision;
}
export type SamplingPolicy =
| { kind: "head"; options: HeadSamplerOptions }
| { kind: "tail"; options: TailSamplerOptions }
| { kind: "rule"; options: RuleSamplerOptions }
| { kind: "all" }
| { kind: "none" };

View File

@@ -0,0 +1,97 @@
# buffered-sink
## Purpose
`BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log record production from the potentially slower I/O performed by the underlying sink, allowing burst writes to be absorbed without blocking callers. The buffer is bounded by a configurable `maxQueueSize`, and when that limit is reached the sink applies one of two overflow policies: silently drop the excess record (`"drop"`) or drain the head record inline before pushing the new one (`"block"`). The `"block"` policy never parks writers in an external waiter list; it always makes forward progress by awaiting `inner.write` of the oldest record before accepting the new one. This eliminates the deadlock hazard of the original "wait until someone calls `flush()` externally" design.
This class is designed for high-throughput server processes where log writes must not stall request handling, and for test harnesses where controlling when records reach the underlying sink is essential for deterministic assertions.
## Features
- Wraps any `Sink` and delegates `write`, `flush`, and `close` calls
- Bounded in-memory queue with configurable `maxQueueSize` (default 1000)
- Two overflow policies: `"drop"` silently discards excess records; `"block"` suspends the caller via a `Promise` until queue capacity frees up
- `dropped` property tracks cumulative records discarded under the `"drop"` policy
- `flush(signal?)` drains the queue sequentially, forwarding each record to the inner sink; throws `LoggerError(LOGGER.FLUSH)` if the provided `AbortSignal` fires mid-drain
- `close()` flushes all pending records then delegates to `inner.close()` if implemented
- Re-entrant flush guard — concurrent `flush()` calls short-circuit rather than double-draining
- `"block"` policy drains inline on overflow — no external `flush()` is required for `write()` to make forward progress, eliminating the deadlock hazard of waiter-list designs
- Constructor rejects non-positive `maxQueueSize` with `LoggerError(LOGGER.CONFIG)`
## Usage: Basic Example
```ts
import { BufferedSink } from "@suaveplan/logger/sink";
import { JsonSink } from "@suaveplan/logger/sink";
const json = new JsonSink({ writer: (line) => process.stdout.write(line + "\n") });
const buffer = new BufferedSink({
sink: json,
maxQueueSize: 500,
overflowPolicy: "drop",
});
// Write a record — enqueued immediately, not yet flushed to stdout
await buffer.write(record);
// Drain all pending records to the underlying JsonSink
await buffer.flush();
```
## Usage: Advanced Example
```ts
import { BufferedSink } from "@suaveplan/logger/sink";
import { JsonSink } from "@suaveplan/logger/sink";
const sink = new BufferedSink({
sink: new JsonSink({ writer: appendToFile }),
maxQueueSize: 2000,
overflowPolicy: "block", // back-pressure: callers await capacity
});
// Graceful shutdown with a 5-second deadline
process.on("SIGTERM", async () => {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 5_000);
try {
await sink.flush(ac.signal);
} catch {
// AbortSignal fired — some records may remain in the queue
console.error(`Dropped ${sink.dropped} records during shutdown`);
} finally {
clearTimeout(timer);
await sink.close();
}
});
```
## API Reference
### `class BufferedSink implements Sink`
**Constructor**: `new BufferedSink(options: BufferedSinkOptions)`
| Option | Type | Default | Description |
|---|---|---|---|
| `sink` | `Sink` | required | The underlying sink that receives flushed records |
| `maxQueueSize` | `number` | `1000` | Maximum queued records before overflow policy triggers |
| `overflowPolicy` | `"drop" \| "block"` | `"drop"` | Action when queue is full |
**Properties**
- `dropped: number` — Cumulative count of records discarded under the `"drop"` policy. Does not reset between flushes.
**Methods**
- `write(record: LogRecord): Promise<void>` — Enqueue a record. Under `"drop"` policy, records beyond `maxQueueSize` are silently counted and discarded. Under `"block"` policy, when the queue is at capacity `write()` first awaits `inner.write` of the head record, then pushes; this ensures forward progress without requiring an external `flush()` and rules out the deadlock that a parked-writer design would expose if no one ever flushed.
- `flush(signal?: AbortSignal): Promise<void>` — Drain the entire queue by forwarding records to the inner sink in FIFO order. Throws `LoggerError(LOGGER.FLUSH)` if the signal is aborted. Throws `LoggerError(LOGGER.SINK)` if the inner sink's `write` rejects. Re-entrant calls return immediately.
- `close(): Promise<void>` — Flush then close the inner sink (if it implements `close()`).
## Implementation Notes
`BufferedSink` uses a simple array queue (`shift()` for FIFO) rather than a ring buffer. For typical logging workloads (hundreds of records per second) the GC pressure is negligible. The `"block"` policy makes forward progress inline (drains the head before pushing) so concurrent overflowing writers serialise on `inner.write` rather than queuing in a waiter list — back-pressure is expressed naturally through awaited promises. The design favours simplicity over micro-optimisation since the bottleneck is always the inner sink's I/O, not the queue operations.
There is no background flush timer — `BufferedSink` is purely demand-driven. If you need periodic auto-flush, start your own `setInterval` that calls `flush()` and clears it on shutdown.

View File

@@ -0,0 +1,92 @@
import { LOGGER_CODES, LoggerError } from "../errors/logger-error.js";
import type { LogRecord } from "../record/log-record.types.js";
import type { BufferedSinkOptions, Sink } from "./sink.types.js";
export class BufferedSink implements Sink {
private readonly inner: Sink;
private readonly maxQueueSize: number;
private readonly overflowPolicy: "drop" | "block";
private queue: LogRecord[] = [];
private droppedCount = 0;
private flushing = false;
public constructor(options: BufferedSinkOptions) {
this.inner = options.sink;
this.maxQueueSize = options.maxQueueSize ?? 1000;
this.overflowPolicy = options.overflowPolicy ?? "drop";
if (!Number.isInteger(this.maxQueueSize) || this.maxQueueSize <= 0) {
throw new LoggerError(
LOGGER_CODES.CONFIG,
"BufferedSink: maxQueueSize must be a positive integer"
);
}
}
public get dropped(): number {
return this.droppedCount;
}
/**
* Append a record to the in-memory queue.
*
* When the queue is already at `maxQueueSize`:
* - `"drop"` — increment the `dropped` counter and return immediately.
* - `"block"` — drain the head record inline (await `inner.write`) so
* capacity becomes available, then push. There is no
* parked-writer list and no external `flush()` is
* required for `write()` to make progress.
*/
public async write(record: LogRecord): Promise<void> {
if (this.queue.length >= this.maxQueueSize) {
if (this.overflowPolicy === "drop") {
this.droppedCount++;
return;
}
// block: drain head inline to reclaim capacity (no external flush needed).
// queue.length >= maxQueueSize > 0 here, so shift() always returns a record.
const head = this.queue.shift() as LogRecord;
try {
await this.inner.write(head);
} catch (err) {
throw new LoggerError(
LOGGER_CODES.SINK,
"BufferedSink: inner sink write failed",
{ cause: err instanceof Error ? err : undefined }
);
}
}
this.queue.push(record);
}
public async flush(signal?: AbortSignal): Promise<void> {
if (this.flushing) return;
this.flushing = true;
try {
while (this.queue.length > 0) {
if (signal?.aborted) {
throw new LoggerError(
LOGGER_CODES.FLUSH,
"BufferedSink.flush aborted via AbortSignal"
);
}
const record = this.queue.shift()!;
try {
await this.inner.write(record);
} catch (err) {
throw new LoggerError(
LOGGER_CODES.SINK,
"BufferedSink: inner sink write failed",
{ cause: err instanceof Error ? err : undefined }
);
}
}
} finally {
this.flushing = false;
}
}
public async close(): Promise<void> {
await this.flush();
if (this.inner.close) await this.inner.close();
}
}

View File

@@ -0,0 +1,89 @@
# console-sink
## Purpose
`ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a single coloured, prefixed line and dispatches it to the appropriate `console` method (`console.debug`, `console.info`, `console.warn`, or `console.error`) so that log output appears at the correct severity level in browser DevTools and Node.js terminals alike.
The sink is intentionally synchronous — `write` returns `void`, not `Promise<void>` — because `console.*` is itself synchronous and any async wrapper would introduce misleading latency for a purely I/O-synchronous operation.
## Features
- ANSI colour codes per log level: cyan for `debug`, green for `info`, yellow for `warn`, red for `error`
- Optional ISO timestamp prefix, dimmed for contrast in colour mode
- Optional `#tag` rendering when the log record carries `ctx.__tags`
- Optional compact JSON metadata block printed below the message line
- All formatting options can be toggled at construction time or by mutating public fields after construction
- Delegates to the correct `console` method for each log level, preserving browser DevTools severity grouping
- No external dependencies beyond the `Sink` interface and `LogRecord` types
## Usage: Basic Example
```ts
import { ConsoleSink } from "@suaveplan/logger/sink";
const sink = new ConsoleSink(); // colors + timestamp enabled by default
sink.write({
ts: new Date().toISOString(),
level: "info",
msg: "Server started",
ctx: {},
});
// Output: [2024-01-01T00:00:00.000Z] [INFO] Server started
```
## Usage: Advanced Example
```ts
import { ConsoleSink } from "@suaveplan/logger/sink";
import { ConsoleLogger } from "@suaveplan/logger/console";
// Plain text for CI environments (no ANSI codes in log files)
const sink = new ConsoleSink({
colors: false,
timestamp: true,
includeMetadata: true,
includeTags: true,
});
const logger = new ConsoleLogger({ sink });
logger.info("Request completed", {
__tags: ["http", "perf"],
duration: 42,
status: 200,
});
// Output:
// [2024-01-01T00:00:00.000Z] [INFO] [#http #perf] Request completed
// {
// "__tags": ["http", "perf"],
// "duration": 42,
// "status": 200
// }
```
## API Reference
### `class ConsoleSink implements Sink`
**Constructor**: `new ConsoleSink(options?: ConsoleSinkOptions)`
All options are also exposed as mutable public fields so they can be toggled after construction (e.g., to disable colours when a terminal is detected as non-interactive).
| Option / Field | Type | Default | Description |
|---|---|---|---|
| `colors` | `boolean` | `true` | Wrap level and timestamp tokens in ANSI colour escapes |
| `timestamp` | `boolean` | `true` | Prefix each line with the record's `ts` value |
| `includeMetadata` | `boolean` | `false` | Append `ctx` as a formatted JSON block below the message |
| `includeTags` | `boolean` | `true` | Inline `ctx.__tags` values as `#tag` tokens |
**Methods**
- `write(record: LogRecord): void` — Format and print the record synchronously. Never throws; `console.*` errors (which are vanishingly rare) surface as uncaught exceptions in the host environment.
## Implementation Notes
ANSI escape codes are defined as a small static map keyed by `LogLevel` plus `"reset"` and `"dim"` sentinels. The formatter builds a `parts: string[]` array and joins once at the end to minimise string concatenation allocations per record.
The sink checks `console[method]` at call time rather than caching the reference at construction, which means it respects any `console` mocking that test frameworks install. This is deliberate — `ConsoleSink` is commonly used in test environments where `console.error` is wrapped to assert that no unexpected errors are logged.
Colour mode does not auto-detect `process.stdout.isTTY`; that decision is left to the caller (typically the factory or the logger bootstrap) so the sink itself remains environment-agnostic and testable without process environment manipulation.

View File

@@ -0,0 +1,74 @@
import type { LogLevel, LogRecord } from "../record/log-record.types.js";
import type { ConsoleSinkOptions, Sink } from "./sink.types.js";
const ANSI: Record<LogLevel | "reset" | "dim", string> = {
debug: "\x1b[36m",
info: "\x1b[32m",
warn: "\x1b[33m",
error: "\x1b[31m",
reset: "\x1b[0m",
dim: "\x1b[2m",
};
const CONSOLE_METHODS: Record<LogLevel, keyof Console> = {
debug: "debug",
info: "info",
warn: "warn",
error: "error",
};
export class ConsoleSink implements Sink {
public colors: boolean;
public timestamp: boolean;
public includeMetadata: boolean;
public includeTags: boolean;
public constructor(options: ConsoleSinkOptions = {}) {
this.colors = options.colors ?? true;
this.timestamp = options.timestamp ?? true;
this.includeMetadata = options.includeMetadata ?? false;
this.includeTags = options.includeTags ?? true;
}
public write(record: LogRecord): void {
const line = this.format(record);
const method = CONSOLE_METHODS[record.level];
(console[method] as (...args: unknown[]) => void)(line);
}
private format(record: LogRecord): string {
const c = this.colors;
const parts: string[] = [];
if (c) parts.push(ANSI[record.level]);
if (this.timestamp) {
if (c) {
parts.push(
`${ANSI.dim}[${record.ts}]${ANSI.reset}${ANSI[record.level]}`
);
} else {
parts.push(`[${record.ts}]`);
}
}
parts.push(`[${record.level.toUpperCase()}]`);
const tags = record.ctx["__tags"];
if (this.includeTags && Array.isArray(tags) && tags.length > 0) {
parts.push(`[${tags.map((t: unknown) => `#${t}`).join(" ")}]`);
}
if (c) parts.push(ANSI.reset);
parts.push(record.msg);
if (this.includeMetadata && Object.keys(record.ctx).length > 0) {
const metaStr = JSON.stringify(record.ctx, null, 2);
parts.push(
c ? `\n${ANSI.dim}${metaStr}${ANSI.reset}` : `\n${metaStr}`
);
}
return parts.join(" ");
}
}

View File

@@ -0,0 +1,10 @@
export { BufferedSink } from "./buffered-sink";
export { ConsoleSink } from "./console-sink";
export { JsonSink } from "./json-sink";
export { closeSink, flushSink, isSink, MultiSink, type Sink } from "./sink";
export type {
BufferedSinkOptions,
ConsoleSinkOptions,
JsonSinkOptions,
OverflowPolicy,
} from "./sink.types";

View File

@@ -0,0 +1,80 @@
# json-sink
## Purpose
`JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a single JSON line and passes it to a configurable `writer` callback — defaulting to `process.stdout.write` — producing newline-delimited JSON (NDJSON) that is directly ingestible by log aggregators such as Fluentd, Logstash, Loki, or Datadog.
The design is deliberately thin: one serialisation step, one write call, no buffering, no formatting decisions. Buffering is the responsibility of `BufferedSink`; formatting decisions belong to the caller. `JsonSink` is the composable primitive that sits at the bottom of a sink stack.
## Features
- Serialises `LogRecord` to a compact JSON line via `JSON.stringify`
- Pluggable `writer` callback — defaults to `process.stdout.write` with a trailing newline
- Throws a structured `LoggerError(LOGGER.SERIALIZE)` if serialisation fails (e.g., circular references in `ctx`)
- Synchronous `write` — no buffering, no internal state; each call is independent
- Trivially composable with `BufferedSink` for async, bounded queuing
- No external runtime dependencies beyond the `LogRecord` type and `LoggerError`
## Usage: Basic Example
```ts
import { JsonSink } from "@suaveplan/logger/sink";
const sink = new JsonSink();
// Writes: {"ts":"2024-01-01T00:00:00.000Z","level":"info","msg":"hello","ctx":{}}\n
sink.write({
ts: new Date().toISOString(),
level: "info",
msg: "hello",
ctx: {},
});
```
## Usage: Advanced Example
```ts
import { JsonSink } from "@suaveplan/logger/sink";
import { BufferedSink } from "@suaveplan/logger/sink";
import { createWriteStream } from "node:fs";
// Write NDJSON to a rotating log file
const stream = createWriteStream("/var/log/app/app.ndjson", { flags: "a" });
const json = new JsonSink({
writer: (line) => stream.write(line + "\n"),
});
// Wrap in a buffer to absorb bursts
const buffered = new BufferedSink({ sink: json, maxQueueSize: 2000 });
// Forward records from the logger to the buffered sink
logger.addSink(buffered);
// Drain and close on process exit
process.on("SIGTERM", async () => {
await buffered.close();
stream.end();
});
```
## API Reference
### `class JsonSink implements Sink`
**Constructor**: `new JsonSink(options?: JsonSinkOptions)`
| Option | Type | Default | Description |
|---|---|---|---|
| `writer` | `(line: string) => void` | `process.stdout.write(line + "\n")` | Receives each serialised JSON line |
**Methods**
- `write(record: LogRecord): void` — Serialise the record and invoke the writer callback. Throws `LoggerError` with code `LOGGER.SERIALIZE` if `JSON.stringify` throws (e.g., due to a circular reference inside `ctx`).
## Implementation Notes
The default `writer` uses `process.stdout.write` rather than `console.log` to avoid the extra newline that `console.log` appends to its own output and to prevent interference with any `console` mocking installed by test frameworks.
`JsonSink` does not implement `flush` or `close` — it has no internal buffer to drain. When wrapped in `BufferedSink`, those lifecycle methods are handled by the buffer layer. This clean separation of concerns keeps `JsonSink` trivially testable with a synchronous `writer` stub.
Because `JSON.stringify` can throw on circular structures, `JsonSink` wraps the call in a try/catch and re-throws as a typed `LoggerError` so callers can distinguish serialisation failures from other sink errors without inspecting error message strings.

View File

@@ -0,0 +1,27 @@
import { LOGGER_CODES, LoggerError } from "../errors/logger-error.js";
import type { LogRecord } from "../record/log-record.types.js";
import type { JsonSinkOptions, Sink } from "./sink.types.js";
export class JsonSink implements Sink {
private readonly writer: (line: string) => void;
public constructor(options: JsonSinkOptions = {}) {
this.writer =
options.writer ??
((line: string) => process.stdout.write(`${line}\n`));
}
public write(record: LogRecord): void {
let line: string;
try {
line = JSON.stringify(record);
} catch (err) {
throw new LoggerError(
LOGGER_CODES.SERIALIZE,
"Failed to serialise log record to JSON",
{ cause: err instanceof Error ? err : undefined }
);
}
this.writer(line);
}
}

View File

@@ -0,0 +1,90 @@
# sink
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The sink layer defines the `Sink` interface — the pluggable output primitive for `@suaveplan/logger` — and provides three concrete implementations: `ConsoleSink` for human-readable terminal output during development, `JsonSink` for line-delimited JSON (NDJSON) suitable for log aggregators, and `BufferedSink` for asynchronous, bounded-queue writes with configurable overflow policy and `AbortSignal`-aware flush. The `MultiSink` utility routes a single record to multiple sinks in parallel.
By decoupling the logger from any specific I/O implementation, the same `AbstractLogger` can write to a terminal, a file, a remote endpoint, or an in-memory buffer without code changes — only the sink configuration differs.
## Features
- `Sink` interface: `write(record)`, optional `flush(signal?)`, optional `close()`
- `ConsoleSink` — ANSI colour per level, ISO timestamp prefix, optional tag and metadata formatting
- `JsonSink` — serialises `LogRecord` to a single JSON line, passes to a pluggable `writer` callback (default: `process.stdout.write`)
- `BufferedSink` — bounded in-memory queue; `"drop"` policy silently discards overflow; `"block"` policy awaits drain; `flush(AbortSignal?)` drains with cancellation support
- `MultiSink` — fan-out to multiple sinks concurrently
- `isSink()` type guard; `flushSink()` / `closeSink()` helpers that no-op for sinks lacking those methods
## Usage: Basic Example
```ts
import { JsonSink } from "@suaveplan/logger/sink";
const sink = new JsonSink({ writer: (line) => process.stdout.write(line + "\n") });
sink.write(record); // {"ts":"...","level":"info","msg":"hello","ctx":{}}
```
## Usage: Advanced Example
```ts
import { BufferedSink } from "@suaveplan/logger/sink";
import { JsonSink } from "@suaveplan/logger/sink";
const json = new JsonSink({ writer: appendToFile });
const buf = new BufferedSink({
sink: json,
maxQueueSize: 500,
overflowPolicy: "drop",
});
// In a long-running process, flush on shutdown:
process.on("SIGTERM", async () => {
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000); // 5 s timeout
await buf.flush(ac.signal);
await buf.close();
});
```
## API Reference
### `interface Sink`
```ts
interface Sink {
write(record: LogRecord): void | Promise<void>;
flush?(signal?: AbortSignal): Promise<void>;
close?(): Promise<void>;
}
```
### `class ConsoleSink implements Sink`
Options: `{ colors?, timestamp?, includeMetadata?, includeTags? }`.
### `class JsonSink implements Sink`
Options: `{ writer? }`. Throws `LoggerError(LOGGER.SERIALIZE)` if `JSON.stringify` fails.
### `class BufferedSink implements Sink`
Options: `{ sink, maxQueueSize?, overflowPolicy? }`. Property `.dropped` counts overflow-dropped records.
### `class MultiSink implements Sink`
**Constructor**: `new MultiSink(sinks: Sink[])`. Writes and flushes all sinks concurrently.
### `isSink(value): value is Sink`
Type guard.
### `flushSink(sink, signal?) / closeSink(sink)`
Helpers that call `sink.flush(signal)` / `sink.close()` only if the method exists.
## Implementation Notes
`BufferedSink` does not start a background interval — it flushes lazily on `flush()` or `close()`. This keeps the class dependency-free (no `Clock` needed) and avoids surprises in test environments. The `"block"` overflow policy uses a simple Promise queue to suspend `write()` callers until the next flush iteration frees capacity.

View File

@@ -0,0 +1,331 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { LogRecord } from "../record/log-record.types.js";
import { BufferedSink } from "./buffered-sink.js";
import { ConsoleSink } from "./console-sink.js";
import { JsonSink } from "./json-sink.js";
import { closeSink, flushSink, isSink, MultiSink } from "./sink.js";
function makeRecord(overrides: Partial<LogRecord> = {}): LogRecord {
return {
ts: new Date().toISOString(),
level: "info",
msg: "hello",
ctx: {},
...overrides,
};
}
describe("isSink", () => {
it("returns true for objects with write()", { timeout: 5000 }, () => {
expect(isSink({ write: () => {} })).toBe(true);
});
it("returns false for non-objects", { timeout: 5000 }, () => {
expect(isSink(null)).toBe(false);
expect(isSink("str")).toBe(false);
expect(isSink({ noWrite: true })).toBe(false);
});
});
describe("flushSink / closeSink", () => {
it("calls flush if present", { timeout: 5000 }, async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const sink = { write: vi.fn(), flush };
await flushSink(sink);
expect(flush).toHaveBeenCalled();
});
it("no-ops if flush absent", { timeout: 5000 }, async () => {
const sink = { write: vi.fn() };
await expect(flushSink(sink)).resolves.toBeUndefined();
});
it("calls close if present", { timeout: 5000 }, async () => {
const close = vi.fn().mockResolvedValue(undefined);
const sink = { write: vi.fn(), close };
await closeSink(sink);
expect(close).toHaveBeenCalled();
});
it("no-ops if close absent", { timeout: 5000 }, async () => {
const sink = { write: vi.fn() };
await expect(closeSink(sink)).resolves.toBeUndefined();
});
});
describe("MultiSink", () => {
it("writes to all sinks", { timeout: 5000 }, async () => {
const a = { write: vi.fn() };
const b = { write: vi.fn() };
const multi = new MultiSink([a, b]);
await multi.write(makeRecord());
expect(a.write).toHaveBeenCalledTimes(1);
expect(b.write).toHaveBeenCalledTimes(1);
});
it("flushes all sinks", { timeout: 5000 }, async () => {
const flushA = vi.fn().mockResolvedValue(undefined);
const flushB = vi.fn().mockResolvedValue(undefined);
const multi = new MultiSink([
{ write: vi.fn(), flush: flushA },
{ write: vi.fn(), flush: flushB },
]);
await multi.flush();
expect(flushA).toHaveBeenCalled();
expect(flushB).toHaveBeenCalled();
});
it("closes all sinks", { timeout: 5000 }, async () => {
const closeA = vi.fn().mockResolvedValue(undefined);
const closeB = vi.fn().mockResolvedValue(undefined);
const multi = new MultiSink([
{ write: vi.fn(), close: closeA },
{ write: vi.fn(), close: closeB },
]);
await multi.close();
expect(closeA).toHaveBeenCalled();
expect(closeB).toHaveBeenCalled();
});
});
describe("ConsoleSink", () => {
it("uses default options when constructed with no arguments", {
timeout: 5000,
}, () => {
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
const sink = new ConsoleSink();
sink.write(makeRecord());
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
});
it("calls console.info for info records", { timeout: 5000 }, () => {
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
const sink = new ConsoleSink({ colors: false, timestamp: false });
sink.write(makeRecord());
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
});
it("formats timestamp with ANSI color when colors=true", {
timeout: 5000,
}, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(String(args[0]));
});
const sink = new ConsoleSink({ colors: true, timestamp: true });
sink.write(makeRecord());
// With colors+timestamp the output should contain ESC codes
expect(lines[0]).toContain("\x1b[");
vi.restoreAllMocks();
});
it("calls console.error for error records", { timeout: 5000 }, () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const sink = new ConsoleSink({ colors: false });
sink.write(makeRecord({ level: "error" }));
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
});
it("includes tags when includeTags=true", { timeout: 5000 }, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(args.join(" "));
});
const sink = new ConsoleSink({
colors: false,
timestamp: false,
includeTags: true,
});
sink.write(makeRecord({ ctx: { __tags: ["auth", "api"] } }));
expect(lines[0]).toContain("#auth");
vi.restoreAllMocks();
});
it("includes metadata when includeMetadata=true", { timeout: 5000 }, () => {
const lines: string[] = [];
vi.spyOn(console, "info").mockImplementation((...args) => {
lines.push(args.join(" "));
});
const sink = new ConsoleSink({
colors: false,
timestamp: false,
includeMetadata: true,
});
sink.write(makeRecord({ ctx: { userId: "u1" } }));
expect(lines[0]).toContain("userId");
vi.restoreAllMocks();
});
});
describe("JsonSink", () => {
it("writes NDJSON lines to writer", { timeout: 5000 }, () => {
const lines: string[] = [];
const sink = new JsonSink({ writer: (l) => lines.push(l) });
sink.write(makeRecord({ msg: "structured" }));
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0]);
expect(parsed.msg).toBe("structured");
expect(parsed.level).toBe("info");
});
it("uses process.stdout when no writer provided", { timeout: 5000 }, () => {
const written: string[] = [];
const spy = vi
.spyOn(process.stdout, "write")
.mockImplementation((chunk) => {
written.push(String(chunk));
return true;
});
const sink = new JsonSink();
sink.write(makeRecord({ msg: "stdout-path" }));
expect(written.some((l) => l.includes("stdout-path"))).toBe(true);
spy.mockRestore();
});
it("throws LoggerError on serialisation failure", { timeout: 5000 }, () => {
const lines: string[] = [];
const sink = new JsonSink({ writer: (l) => lines.push(l) });
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(() => sink.write(makeRecord({ ctx: circular }))).toThrowError();
});
it("wraps non-Error thrown by JSON.stringify with undefined cause", {
timeout: 5000,
}, () => {
const orig = JSON.stringify;
(JSON as any).stringify = () => {
throw "string-error";
};
const sink = new JsonSink({ writer: () => {} });
try {
expect(() => sink.write(makeRecord())).toThrowError();
} finally {
(JSON as any).stringify = orig;
}
});
});
describe("BufferedSink", () => {
it("buffers and flushes records", { timeout: 5000 }, async () => {
const written: LogRecord[] = [];
const inner = {
write: async (r: LogRecord) => {
written.push(r);
},
};
const buf = new BufferedSink({ sink: inner });
await buf.write(makeRecord({ msg: "a" }));
await buf.write(makeRecord({ msg: "b" }));
expect(written).toHaveLength(0);
await buf.flush();
expect(written).toHaveLength(2);
});
it("block overflow policy resolves after flush", {
timeout: 5000,
}, async () => {
const written: LogRecord[] = [];
const inner = {
write: async (r: LogRecord) => {
written.push(r);
},
};
const buf = new BufferedSink({
sink: inner,
maxQueueSize: 1,
overflowPolicy: "block",
});
await buf.write(makeRecord({ msg: "first" }));
// Second write would block; flush first to unblock it
const writePromise = buf.write(makeRecord({ msg: "second" }));
await buf.flush();
await writePromise;
await buf.flush();
expect(written).toHaveLength(2);
});
it("flush is idempotent when already flushing", {
timeout: 5000,
}, async () => {
const written: LogRecord[] = [];
const inner = {
write: async (r: LogRecord) => {
written.push(r);
},
};
const buf = new BufferedSink({ sink: inner });
await buf.write(makeRecord());
const p1 = buf.flush();
const p2 = buf.flush(); // concurrent second flush — hits flushing guard
await Promise.all([p1, p2]);
expect(written).toHaveLength(1);
});
it("drops records on overflow with 'drop' policy", {
timeout: 5000,
}, async () => {
const inner = { write: vi.fn() };
const buf = new BufferedSink({
sink: inner,
maxQueueSize: 2,
overflowPolicy: "drop",
});
await buf.write(makeRecord());
await buf.write(makeRecord());
await buf.write(makeRecord()); // dropped
expect(buf.dropped).toBe(1);
await buf.flush();
expect(inner.write).toHaveBeenCalledTimes(2);
});
it("aborts flush via AbortSignal", { timeout: 5000 }, async () => {
const inner = { write: vi.fn() };
const buf = new BufferedSink({ sink: inner });
await buf.write(makeRecord());
const ac = new AbortController();
ac.abort();
await expect(buf.flush(ac.signal)).rejects.toThrow();
});
it("closes inner sink on close()", { timeout: 5000 }, async () => {
const close = vi.fn().mockResolvedValue(undefined);
const inner = { write: vi.fn(), close };
const buf = new BufferedSink({ sink: inner });
await buf.close();
expect(close).toHaveBeenCalled();
});
it("wraps inner write failure in LoggerError", {
timeout: 5000,
}, async () => {
const inner = {
write: vi.fn().mockRejectedValue(new Error("disk full")),
};
const buf = new BufferedSink({ sink: inner });
await buf.write(makeRecord());
await expect(buf.flush()).rejects.toThrow("inner sink write failed");
});
it("wraps non-Error inner write failure with undefined cause", {
timeout: 5000,
}, async () => {
// Make inner throw a string (not an Error) to cover the `err instanceof Error ? err : undefined` branch
const inner = { write: vi.fn().mockRejectedValue("string-error") };
const buf = new BufferedSink({ sink: inner });
await buf.write(makeRecord());
await expect(buf.flush()).rejects.toThrow("inner sink write failed");
});
it("close() works when inner has no close method", {
timeout: 5000,
}, async () => {
const inner = { write: vi.fn().mockResolvedValue(undefined) };
const buf = new BufferedSink({ sink: inner });
await buf.write(makeRecord());
await expect(buf.close()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,44 @@
import type { LogRecord } from "../record/log-record.types.js";
import type { Sink } from "./sink.types.js";
export type { Sink };
export function isSink(value: unknown): value is Sink {
return (
typeof value === "object" &&
value !== null &&
"write" in value &&
typeof (value as Sink).write === "function"
);
}
export async function flushSink(
sink: Sink,
signal?: AbortSignal
): Promise<void> {
if (sink.flush) await sink.flush(signal);
}
export async function closeSink(sink: Sink): Promise<void> {
if (sink.close) await sink.close();
}
export class MultiSink implements Sink {
private readonly sinks: Sink[];
public constructor(sinks: Sink[]) {
this.sinks = sinks;
}
public async write(record: LogRecord): Promise<void> {
await Promise.all(this.sinks.map((s) => s.write(record)));
}
public async flush(signal?: AbortSignal): Promise<void> {
await Promise.all(this.sinks.map((s) => flushSink(s, signal)));
}
public async close(): Promise<void> {
await Promise.all(this.sinks.map((s) => closeSink(s)));
}
}

View File

@@ -0,0 +1,34 @@
import type { LogRecord } from "../record/log-record.types.js";
export interface Sink {
write(record: LogRecord): void | Promise<void>;
flush?(signal?: AbortSignal): Promise<void>;
close?(): Promise<void>;
}
export type OverflowPolicy = "drop" | "block";
export interface BufferedSinkOptions {
/** Wrapped sink to receive flushed records */
sink: Sink;
/** Maximum records to queue before applying overflow policy (default 1000) */
maxQueueSize?: number;
/** What to do when queue is full (default "drop") */
overflowPolicy?: OverflowPolicy;
}
export interface ConsoleSinkOptions {
/** Enable ANSI colour codes (default true) */
colors?: boolean;
/** Prefix each line with an ISO timestamp (default true) */
timestamp?: boolean;
/** Append metadata as compact JSON (default false) */
includeMetadata?: boolean;
/** Inline tags as `#tag` tokens (default true) */
includeTags?: boolean;
}
export interface JsonSinkOptions {
/** Called with each NDJSON line (default: process.stdout.write) */
writer?: (line: string) => void;
}

View File

@@ -0,0 +1,2 @@
export { type StorageInterface, StorageLogger } from "./storage-logger";
export type { StorageLoggerConfig } from "./storage-logger.types";

View File

@@ -0,0 +1,100 @@
# storage-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StorageLogger` persists log batches to any key-value store that implements the `StorageInterface` contract (set/get/list/delete with `Uint8Array` values). It extends `AbstractLogger` so all enterprise features (redaction, sampling, OTel, clock injection, metrics) run before any storage write. Log entries are buffered in memory and flushed either automatically on a configurable interval, when the buffer reaches `maxBatchSize`, or manually via `flush()`. Retrieved logs can be filtered by date range and limited in count.
## Features
- Buffered writes: accumulate entries in-memory, flush as a single JSON batch keyed by timestamp
- Auto-flush on interval (configurable, default 5 s) or manual `flush(signal?)`
- `maxBatchSize` threshold triggers an immediate async flush
- `getLogs({ limit?, startDate?, endDate?, signal? })` retrieves and deserialises all stored batches
- `clearLogs(olderThan?)` removes batches (optionally filtered by timestamp)
- `destroy()` stops the auto-flush timer and flushes remaining buffer
- Injectable `StorageInterface` — works with any KV store (Cloudflare KV, IndexedDB shims, in-memory)
## Usage: Basic Example
```ts
import { StorageLogger } from "@suaveplan/logger/storage";
const logger = new StorageLogger(cloudflareKV, { level: "info" });
logger.info("Request received", { path: "/api/users" });
// Automatically flushed every 5 s
```
## Usage: Advanced Example
```ts
import { StorageLogger } from "@suaveplan/logger/storage";
const logger = new StorageLogger(storage, {
level: "debug",
storagePrefix: "app:logs",
maxBatchSize: 50,
flushInterval: 10_000,
autoFlush: true,
});
// Manual flush with abort support:
const ac = new AbortController();
setTimeout(() => ac.abort(), 3_000);
await logger.flush(ac.signal);
// Retrieve last 100 error logs in last hour:
const recent = await logger.getLogs({
limit: 100,
startDate: new Date(Date.now() - 3_600_000),
});
// Teardown:
await logger.destroy();
```
## API Reference
### `class StorageLogger<TMetadata> extends AbstractLogger<TMetadata>`
**Constructor**: `new StorageLogger(storage, config?)`
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `storagePrefix` | `string` | `"logs"` | Key prefix for stored batches |
| `maxBatchSize` | `number` | `100` | Triggers immediate flush when reached |
| `flushInterval` | `number` | `5000` | Auto-flush interval in ms |
| `autoFlush` | `boolean` | `true` | Enable interval-based flushing |
#### `flush(signal?: AbortSignal): Promise<void>`
Writes the current buffer to storage. Aborts if `signal` is already aborted before the write.
#### `getLogs(options?): Promise<LogEntry<TMetadata>[]>`
Options: `{ limit?, startDate?, endDate?, signal? }`. Returns entries sorted newest-first.
#### `clearLogs(olderThan?: Date): Promise<void>`
Deletes all stored batches, or only those older than `olderThan`.
#### `destroy(): Promise<void>`
Clears the auto-flush timer and flushes remaining buffer.
### `interface StorageInterface`
```ts
interface StorageInterface {
set(key: string, value: Uint8Array): Promise<void>;
get(key: string): Promise<Uint8Array | null>;
list(options?: { prefix?: string }): Promise<{ keys: string[] }>;
delete(key: string): Promise<void>;
}
```
## Implementation Notes
Each flush writes a JSON array of `LogEntry` objects under the key `${prefix}:${ISO-timestamp}:${uuid}`. The UUID prevents key collisions when multiple instances flush at the same millisecond. `extractTimestamp` reverse-engineers the timestamp by finding the last `:` separator, which is robust because the ISO timestamp itself contains colons. On flush failure, the buffered entries are re-queued at the front of the buffer so no records are silently lost.

View File

@@ -0,0 +1,317 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { StorageLogger } from "./storage-logger.js";
import type { StorageInterface } from "./storage-logger.types.js";
function makeStorage(): StorageInterface & { store: Map<string, Uint8Array> } {
const store = new Map<string, Uint8Array>();
return {
store,
set: vi.fn(async (key, value) => {
store.set(key, value);
}),
get: vi.fn(async (key) => store.get(key) ?? null),
list: vi.fn(async (opts) => {
const prefix = opts?.prefix ?? "";
return {
keys: [...store.keys()].filter((k) => k.startsWith(prefix)),
};
}),
delete: vi.fn(async (key) => {
store.delete(key);
}),
};
}
describe("StorageLogger — write and flush", () => {
it("flushes to storage on destroy()", { timeout: 30000 }, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, {
level: "debug",
autoFlush: false,
});
logger.info("a");
logger.info("b");
await logger.destroy();
expect(storage.store.size).toBe(1);
});
it("batches at maxBatchSize", { timeout: 30000 }, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, {
level: "debug",
autoFlush: false,
maxBatchSize: 2,
});
logger.info("x");
logger.info("y"); // triggers auto-flush
// Allow microtask queue to drain
await new Promise((r) => setTimeout(r, 10));
expect(storage.store.size).toBe(1);
await logger.destroy();
});
it("getLogs retrieves and parses entries", { timeout: 30000 }, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, {
level: "debug",
autoFlush: false,
});
logger.info("msg1");
logger.warn("msg2");
await logger.flush();
const logs = await logger.getLogs();
expect(logs).toHaveLength(2);
expect(logs.map((l) => l.message).sort()).toEqual(["msg1", "msg2"]);
});
it("getLogs with limit restricts by batch key count", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
// 3 separate flushes = 3 batch keys
logger.info("a");
await logger.flush();
logger.info("b");
await logger.flush();
logger.info("c");
await logger.flush();
expect(storage.store.size).toBe(3);
// limit: 1 returns only the 1 most-recent batch key
const limited = await logger.getLogs({ limit: 1 });
expect(limited).toHaveLength(1);
});
it("clearLogs removes all entries when no date filter", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("keep");
await logger.flush();
await logger.clearLogs();
expect(storage.store.size).toBe(0);
});
it("flush re-queues on storage failure", { timeout: 30000 }, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("msg");
vi.spyOn(storage, "set").mockRejectedValueOnce(new Error("io"));
await expect(logger.flush()).rejects.toThrow("io");
// re-queued: should be flushed on next attempt
vi.restoreAllMocks();
await logger.flush();
expect(storage.store.size).toBe(1);
});
it("getLogs with startDate/endDate filters by timestamp", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("msg1");
await logger.flush();
await new Promise((r) => setTimeout(r, 10));
const mid = new Date();
await new Promise((r) => setTimeout(r, 10));
logger.info("msg2");
await logger.flush();
// Only entries after `mid` should be returned
const logs = await logger.getLogs({ startDate: mid });
expect(logs.map((l) => l.message)).toContain("msg2");
expect(logs.map((l) => l.message)).not.toContain("msg1");
});
it("clearLogs(olderThan) removes only entries before the date", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("old");
await logger.flush();
// Small delay so timestamps differ
await new Promise((r) => setTimeout(r, 10));
const boundary = new Date();
logger.info("new");
await logger.flush();
expect(storage.store.size).toBe(2);
await logger.clearLogs(boundary);
// Only the old entry should be removed
expect(storage.store.size).toBe(1);
});
it("starts auto-flush timer when autoFlush=true", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, {
autoFlush: true,
flushInterval: 50,
});
logger.info("auto");
await new Promise((r) => setTimeout(r, 150));
await logger.destroy();
expect(storage.store.size).toBeGreaterThanOrEqual(1);
});
it("flush respects an already-aborted AbortSignal", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("aborted");
const ac = new AbortController();
ac.abort();
await logger.flush(ac.signal);
// Signal was aborted before storage.set — nothing written
expect(storage.store.size).toBe(0);
});
it("getLogs with endDate excludes entries after the date", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("early");
await logger.flush();
await new Promise((r) => setTimeout(r, 10));
const cutoff = new Date();
await new Promise((r) => setTimeout(r, 10));
logger.info("late");
await logger.flush();
const logs = await logger.getLogs({ endDate: cutoff });
expect(logs.map((l) => l.message)).toContain("early");
expect(logs.map((l) => l.message)).not.toContain("late");
});
it("getLogs ignores keys without valid timestamp format", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("valid");
await logger.flush();
// Inject a malformed key (no UUID segment → extractTimestamp returns undefined)
const encoder = new TextEncoder();
storage.store.set(
"logs:no-uuid",
encoder.encode(JSON.stringify([{ message: "bad" }]))
);
// getLogs with date filter should skip malformed keys
const mid = new Date(0);
const logs = await logger.getLogs({ startDate: mid });
expect(logs.map((l) => l.message)).toContain("valid");
});
it("getLogs with aborted signal stops fetching early", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("a");
await logger.flush();
const ac = new AbortController();
ac.abort();
const logs = await logger.getLogs({ signal: ac.signal });
expect(Array.isArray(logs)).toBe(true);
});
it("constructor uses defaults when no config provided", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
// No config = {} default, autoFlush ?? true, storagePrefix ?? "logs"
const logger = new StorageLogger(storage);
logger.info("default-config");
await logger.destroy(); // stops timer and flushes
expect(storage.store.size).toBe(1);
});
it("getLogs sort handles malformed keys via ?? empty string fallback", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("real");
await logger.flush();
// Two malformed keys — ensures sort comparator hits undefined on both sides
const encoder = new TextEncoder();
storage.store.set(
"logs:no-colon-a",
encoder.encode(JSON.stringify([]))
);
storage.store.set(
"logs:no-colon-b",
encoder.encode(JSON.stringify([]))
);
// getLogs without date filter: sort runs with extractTimestamp returning undefined → ?? "" fires
const logs = await logger.getLogs();
expect(Array.isArray(logs)).toBe(true);
});
it("getLogs skips keys whose storage.get returns null", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("real");
await logger.flush();
// Override get so it returns null for the first key
const _origGet = storage.get.getMockImplementation();
vi.spyOn(storage, "get").mockResolvedValueOnce(null);
const logs = await logger.getLogs();
// The null-bytes entry is skipped; no crash
expect(Array.isArray(logs)).toBe(true);
vi.restoreAllMocks();
});
it("maxBatchSize flush failure is swallowed silently", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
vi.spyOn(storage, "set").mockRejectedValue(new Error("write-error"));
const logger = new StorageLogger(storage, {
autoFlush: false,
maxBatchSize: 1,
});
// Writing the first log triggers flush which will fail — should not throw
logger.info("overflow");
await new Promise((r) => setTimeout(r, 20));
vi.restoreAllMocks();
await logger.destroy().catch(() => {}); // destroy may fail; that's fine
});
it("auto-flush timer failure is swallowed silently", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, {
autoFlush: true,
flushInterval: 50,
});
logger.info("timer-fail");
vi.spyOn(storage, "set").mockRejectedValue(
new Error("timer-write-error")
);
await new Promise((r) => setTimeout(r, 150));
vi.restoreAllMocks();
await logger.destroy().catch(() => {}); // destroy may also fail
});
it("clearLogs skips keys that do not start with storagePrefix:", {
timeout: 30000,
}, async () => {
const storage = makeStorage();
const logger = new StorageLogger(storage, { autoFlush: false });
logger.info("mine");
await logger.flush();
// "logs" prefix match but NOT "logs:" — triggers the !startsWith continue guard
const encoder = new TextEncoder();
storage.store.set("logs-foreign-key", encoder.encode("{}"));
await logger.clearLogs();
// Foreign key must survive (skipped by the guard); logger's "logs:" keys are removed
expect(storage.store.has("logs-foreign-key")).toBe(true);
expect(storage.store.size).toBe(1); // only the foreign key remains
});
});

View File

@@ -0,0 +1,151 @@
import { AbstractLogger } from "../abstract/abstract-logger.js";
import type { LogEntry } from "../record/log-record.types.js";
import type {
StorageInterface,
StorageLoggerConfig,
} from "./storage-logger.types.js";
const ENCODER = new TextEncoder();
const DECODER = new TextDecoder();
export class StorageLogger<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends AbstractLogger<TMetadata> {
private readonly storage: StorageInterface;
private readonly storagePrefix: string;
private readonly maxBatchSize: number;
private readonly flushIntervalMs: number;
private readonly autoFlush: boolean;
private logBuffer: LogEntry<TMetadata>[] = [];
private flushTimer: ReturnType<typeof setInterval> | undefined;
public constructor(
storage: StorageInterface,
config: StorageLoggerConfig<TMetadata> = {}
) {
super(config);
this.storage = storage;
this.storagePrefix = config.storagePrefix ?? "logs";
this.maxBatchSize = config.maxBatchSize ?? 100;
this.flushIntervalMs = config.flushInterval ?? 5000;
this.autoFlush = config.autoFlush ?? true;
if (this.autoFlush) this.startFlushTimer();
}
protected writeLog(entry: LogEntry<TMetadata>): void {
this.logBuffer.push(entry);
if (this.logBuffer.length >= this.maxBatchSize) {
this.flush().catch(() => {
// drop silently; metrics will reflect backpressure via AbstractLogger
});
}
}
public async flush(signal?: AbortSignal): Promise<void> {
if (this.logBuffer.length === 0) return;
const logsToFlush = [...this.logBuffer];
this.logBuffer = [];
const ts = new Date().toISOString();
const key = `${this.storagePrefix}:${ts}:${crypto.randomUUID()}`;
const bytes = ENCODER.encode(JSON.stringify(logsToFlush));
try {
if (signal?.aborted) return;
await this.storage.set(key, bytes);
} catch (err) {
this.logBuffer.unshift(...logsToFlush);
throw err;
}
}
public async getLogs(
options: {
limit?: number;
startDate?: Date;
endDate?: Date;
signal?: AbortSignal;
} = {}
): Promise<LogEntry<TMetadata>[]> {
const { keys } = await this.storage.list({
prefix: this.storagePrefix,
});
let filtered = keys.filter((k) =>
k.startsWith(`${this.storagePrefix}:`)
);
if (options.startDate || options.endDate) {
filtered = filtered.filter((k) => {
const ts = this.extractTimestamp(k);
if (!ts) return false;
const d = new Date(ts);
if (options.startDate && d < options.startDate) return false;
if (options.endDate && d > options.endDate) return false;
return true;
});
}
filtered.sort((a, b) => {
const ta = this.extractTimestamp(a) ?? "";
const tb = this.extractTimestamp(b) ?? "";
return tb.localeCompare(ta);
});
if (options.limit) filtered = filtered.slice(0, options.limit);
const all: LogEntry<TMetadata>[] = [];
for (const key of filtered) {
if (options.signal?.aborted) break;
const bytes = await this.storage.get(key);
if (!bytes) continue;
try {
const entries = JSON.parse(
DECODER.decode(bytes)
) as LogEntry<TMetadata>[];
for (const e of entries) e.timestamp = new Date(e.timestamp);
all.push(...entries);
} catch {
// skip corrupt batch
}
}
return all.sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
}
public async clearLogs(olderThan?: Date): Promise<void> {
const { keys } = await this.storage.list({
prefix: this.storagePrefix,
});
for (const key of keys) {
if (!key.startsWith(`${this.storagePrefix}:`)) continue;
if (olderThan) {
const ts = this.extractTimestamp(key);
if (!ts || new Date(ts) >= olderThan) continue;
}
await this.storage.delete(key);
}
}
public async destroy(): Promise<void> {
if (this.flushTimer !== undefined) {
clearInterval(this.flushTimer);
this.flushTimer = undefined;
}
await this.flush();
}
private startFlushTimer(): void {
this.flushTimer = setInterval(() => {
this.flush().catch(() => {});
}, this.flushIntervalMs);
}
private extractTimestamp(key: string): string | undefined {
const withoutPrefix = key.slice(this.storagePrefix.length + 1);
const last = withoutPrefix.lastIndexOf(":");
if (last === -1) return undefined;
return withoutPrefix.slice(0, last);
}
}
export type { StorageInterface };

View File

@@ -0,0 +1,17 @@
import type { AbstractLoggerConfig } from "../abstract/abstract-logger.types.js";
export interface StorageInterface {
set(key: string, value: Uint8Array): Promise<void>;
get(key: string): Promise<Uint8Array | null>;
list(options?: { prefix?: string }): Promise<{ keys: string[] }>;
delete(key: string): Promise<void>;
}
export interface StorageLoggerConfig<
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> extends AbstractLoggerConfig<TMetadata> {
storagePrefix?: string;
maxBatchSize?: number;
flushInterval?: number;
autoFlush?: boolean;
}

View File

@@ -0,0 +1,10 @@
import { webcrypto } from "node:crypto";
// Polyfill Web Crypto API for Node/Bun test environments that lack it
if (typeof globalThis.crypto === "undefined") {
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
configurable: true,
writable: true,
});
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,41 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/logger",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: "src/index.ts",
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});