Skip to main content

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:

  • level is a non-empty string
  • every level method exists and is callable in both overload forms
  • null and cyclic payloads don't throw
  • child(bindings) returns a Logger satisfying the same contract; child loggers can themselves spawn children
  • dispose is 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โ€‹