Writing a custom Logger adapter
Logger is the observability port. The framework ships ConsoleLogger (default) and @rotorsoft/act-pino. A custom adapter is a wrapper around your preferred logging library (winston, bunyan, an OpenTelemetry log exporter, etc.) that conforms to the Logger interface.
The contractโ
From libs/act/src/types/ports.ts:
interface Logger extends Disposable {
level: string;
fatal(obj: unknown, msg?: string): void;
fatal(msg: string): void;
error(obj: unknown, msg?: string): void;
error(msg: string): void;
// โฆ warn, info, debug, trace โ each with both overloads
child(bindings: Record<string, unknown>): Logger;
}
Every level method takes either (msg) or (obj, msg?). child(bindings) returns something satisfying the same contract โ bindings are layered context (request id, tenant, correlation id, โฆ).
The contract is intentionally narrow: the framework treats loggers as pluggable sinks. Output format is not part of the contract โ pretty-printed dev output, NDJSON for production, OpenTelemetry log records โ all are valid as long as the methods exist and behave.
The TCK is the specโ
// libs/act-winston/test/logger-tck.spec.ts
import { runLoggerTck } from "@rotorsoft/act-tck";
import { WinstonLogger } from "../src/index.js";
runLoggerTck({
name: "WinstonLogger",
factory: () => new WinstonLogger({ level: "trace" }),
});
The TCK is a structural smoke test:
levelis a non-empty string- every level method exists and is callable in both overload forms
nulland cyclic payloads don't throwchild(bindings)returns a Logger satisfying the same contract; child loggers can themselves spawn childrendisposeis idempotent and awaitable
It deliberately does not assert on what gets written โ that's adapter-specific by design. Your own test suite is where you check that the right thing lands in the right sink.
Scaffolding @rotorsoft/act-winston (sketch)โ
libs/act-winston/
โโโ package.json
โโโ tsconfig.json
โโโ tsconfig.build.json
โโโ tsup.config.ts
โโโ src/
โ โโโ index.ts
โ โโโ winston-logger.ts # implements Logger
โโโ test/
โ โโโ logger-tck.spec.ts # runLoggerTck({ factory: () => new WinstonLogger(โฆ) })
โ โโโ transports.spec.ts # adapter-specific transport/format assertions
โโโ README.md
The README's testing section:
## Testing
```ts
import { runLoggerTck } from "@rotorsoft/act-tck";
import { WinstonLogger } from "@rotorsoft/act-winston";
runLoggerTck({
name: "WinstonLogger",
factory: () => new WinstonLogger({ level: "trace" }),
});
```
When the Logger port changesโ
If the framework extends the Logger interface, matching cases land in libs/act-tck/src/logger-tck.ts. Because the contract is narrow, breaking changes are rare โ the most likely evolution is a new structured method (flush, withSpan, โฆ) added behind a capability flag.
Cross-referencesโ
- Contract:
libs/act/src/types/ports.ts - Reference implementations:
- TCK source:
libs/act-tck/src/logger-tck.ts