@setu-ts/logger-plugin

Structured logging. Registers an ILogger under CAPABILITIES.LOGGER ('logger') and, optionally, request/response logging middleware.

Three implementations ship: ConsoleLogger (zero-dependency default), PinoLogger (over pino, lazily imported or injected), and NoopLogger.

Installation

import { LoggerPlugin } from '@setu-ts/logger-plugin';

Usage

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { LoggerPlugin } from '@setu-ts/logger-plugin';
import { CAPABILITIES, type ILogger } from '@setu-ts/common';

const app = createApplication({
  plugins: [RuntimePlugin(), LoggerPlugin({ transport: 'console', level: 'info' })],
});
await app.start({ port: 3000 });

const logger = app.services.get<ILogger>(CAPABILITIES.LOGGER);
logger.info('server ready', { port: 3000 });

Options

The plugin’s excludePaths accepts the same readonly PathPattern[] as request middleware: strings match exactly and regular expressions match the request path.

errorHandler owns the caught-error diagnostic: one warn below 500, one error for 5xx. Request logging records completion with the error responder’s predicted status and rethrows; without a responder it records 500. A custom ErrorHandlerOptions.respond hook can choose a different served status; request completion still reports the rule’s status. Request logging stays inside telemetry so trace identifiers remain available.

Without a registered logger, unhandled kernel errors and handled 5xx use the runtime’s reportError sink (one JSON line to stderr; Workers uses its platform log channel). Handled 4xx stays silent without a logger. Registering a logger selects it exclusively, including a no-op logger.

OptionTypeDefaultDescription
levelLogLevel'info'Minimum level to emit.
transportLoggerTransport'console'Implementation: 'console', 'pino', 'noop', or 'custom'.
loggerILogger—Required only with 'custom'; refused with other transports. Since 0.11.0.
prettybooleanfalsePretty-print entries (console transport only).
redactreadonly string[]Secret-field patternsDot-paths to redact from metadata; pass [] to opt out of the secure defaults.
redactionRedactionPolicy | IRedactionService—Shared policy or service applied before log egress.
requestLoggingbooleanfalseRegister request/response logging middleware.
slowRequestThresholdnumber5000Requests slower than this (ms) log at warn.
excludePathsreadonly PathPattern[][]Paths excluded from request logging. A string is an EXACT match; a RegExp is tested against the path.
pinoFactoryPinoFactory—Inject a pre-loaded Pino factory, skipping the import.

Pino

pino is an optional dependency, lazily imported only when transport: 'pino' is configured — a console-logging application never pulls it in. You may also inject a pre-built factory via PinoLoggerOptions to skip the import entirely.

Custom logger

Since 0.11.0, transport: 'custom' accepts an ILogger adapter. Level filtering, default secret redaction, explicit redact paths, a redaction policy/service, request logging and trace correlation apply before metadata reaches your sink. Children delegate to the adapter’s child() with redacted bindings and keep these controls. Invalid logger/transport combinations throw an Error named LoggerConfigurationError at plugin construction. Your sink may filter further. As on every transport, a log call never throws: metadata that throws while being read is replaced by [unserializable metadata], and if your sink throws on a log call, that record is dropped rather than failing the request that logged it. If your sink’s child() throws, the child keeps the parent sink and its records carry none of the new bindings (such as requestId). The first sink failure is reported once on stderr, naming only the error’s class, so a broken sink does not go unnoticed.

For example, adapt an existing Winston-shaped sink without adding a framework dependency:

import type { ILogger, LogLevel, LogMetadata } from '@setu-ts/common';

interface IWinstonSink {
  log(level: string, message: string, metadata?: LogMetadata): void;
  child(bindings: LogMetadata): IWinstonSink;
}

function adapt(sink: IWinstonSink): ILogger {
  const emit = (level: LogLevel) => (message: string, metadata?: LogMetadata): void =>
    sink.log(level === 'fatal' ? 'error' : level === 'trace' ? 'debug' : level, message, metadata);
  return {
    level: 'trace',
    fatal: emit('fatal'), error: emit('error'), warn: emit('warn'),
    info: emit('info'), debug: emit('debug'), trace: emit('trace'),
    child: (bindings) => adapt(sink.child(bindings)),
  };
}

// Your application's existing logger instance.
declare const existingSink: IWinstonSink;
LoggerPlugin({ transport: 'custom', logger: adapt(existingSink), requestLogging: true });

Request logging

Since 0.11.0, createRequestLoggerMiddleware(options) emits one info entry per request: request completed, carrying method, path, status, duration and the child logger’s requestId. For example, console output is a self-contained JSON line:

{"level":"info","time":1730000000000,"msg":"request completed","requestId":"req-1","method":"GET","path":"/users","status":200,"duration":12}

request received now logs at debug. Set LoggerPlugin({ requestLogging: true, level: 'debug' }) to emit both arrival and completion again. When the child’s exposed level excludes debug, the middleware skips building arrival metadata; if no level is readable, it delegates filtering to debug(). Slow requests still add a warn entry and thrown errors retain their predicted completion status. Durations use the monotonic clock (runtime.hrtime()), never a wall-clock epoch.

The request logger records method and the concrete path (no query string) on receive and on a slow request — the path as the client sent it, not a route template. The database-plugin scrub covers database errors only, so an identifier in a path segment reaches the log. To keep it out: excludePaths (skips the request’s records), a redaction field redactor for path, or redact (which replaces the default secret patterns, so re-list them).

The scaffolded templates opt in with requestLogging: true and exclude /live, /ready, /health and /metrics, so probe traffic writes no line. The plugin’s own default stays false.

Trace correlation

With a CAPABILITIES.TELEMETRY provider registered, every record carries the active span’s trace_id and span_id, so an operator holding a trace id can find the log lines and one holding a log line can find the trace.

{
  "level": "info",
  "msg": "order created",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "span_id": "b7ad6b7169203331"
}

The names are snake_case rather than this framework’s usual camelCase, deliberately: they exist to be read by a log backend, and the OpenTelemetry log-correlation convention Loki, Elastic and the collector’s own processors key on is snake_case.

Enrichment covers every level and child loggers, so logger.child({ requestId }) still carries them. Your own trace_id in the metadata wins. Without telemetry, or outside any span, the record is byte-identical to before — as it is when the telemetry service cannot report an active span, or when reading it throws: the read is guarded end to end, because an observability enrichment must never turn logging into the fault.

The value registered under CAPABILITIES.LOGGER is an internal decorator wrapping the configured transport, so instanceof ConsoleLogger on the resolved capability does not hold. ILogger is the contract.

Redaction

Loggers apply redaction after normalizing metadata and before the legacy redact list. Supply a shared RedactionPolicy (or an IRedactionService) to use the same policy in other egress plugins:

import { DATA_CLASSIFICATIONS } from '@setu-ts/common';

LoggerPlugin({
  redaction: { fields: { 'user.email': DATA_CLASSIFICATIONS.PII } },
});

A fields value is either a classification — a DATA_CLASSIFICATIONS member ('pii', 'phi', 'pci', 'secret') or an application-defined string the framework does not know, which reaches redactors[<string>] — or a FieldRedaction ({ classification, redactor? }) carrying a per-pattern redactor. See the common Redaction section for the full policy shape and the selection precedence.

The legacy redact option remains supported and wins when it overlaps a policy. With no explicit redact list, common secret-shaped fields are redacted by default with case-insensitive matching; set redact: [] to restore the previous no-default-redaction behaviour.

Exports

ExportKind
createRequestLoggerMiddlewarefunction
LoggerPluginfunction
ConsoleLoggerclass
NoopLoggerclass
PinoLoggerclass
ConsoleLoggerOptionsinterface
LoggerPluginOptionsinterface
NoopLoggerOptionsinterface
PinoLoggerOptionsinterface
RequestLoggerOptionsinterface
LoggerTransporttype
PinoFactorytype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

LoggerPlugin() (@setu-ts/logger-plugin)

LoggerPluginOptions.excludePaths?: readonly PathPattern[] passes strings and regular expressions unchanged to request middleware. Strings match exactly; regular expressions test the request path.

OptionTypeConsumer
LoggerPluginOptions.excludePathsreadonly PathPattern[]Request-logger middleware path matcher

The request logger emits completion with the status predicted by IErrorResponder.statusFor? on a throw, or 500 without that method; normal returns use the response snapshot. It rethrows without a duplicate failure record. errorHandler logs once at warn below 500 and error otherwise. A custom ErrorHandlerOptions.respond hook choosing another status is not reflected in request completion logs. Request logging remains inside telemetry. With no logger, handled 5xx and kernel unhandled errors use the runtime’s default sink; a registered logger is the exclusive destination.

Provides structured logging via a capability token. The plugin depends on RuntimePlugin and registers its ILogger under CAPABILITIES.LOGGER at PLUGIN_PRIORITY.HIGH (100) so logging is available before most other plugins register.

Registration

import { LoggerPlugin } from '@setu-ts/logger-plugin';

app.register(
  LoggerPlugin({
    level: 'info', // minimum level to emit (default 'info')
    transport: 'console', // 'console' | 'pino' | 'noop' | 'custom' (default 'console')
    pretty: false, // pretty-print console output
    redact: ['password', 'token', 'authorization'], // dot-paths to redact
    requestLogging: true, // register request/response middleware
    slowRequestThreshold: 5000, // ms — warn when slower (default 5000)
    excludePaths: ['/health'], // paths excluded from request logging
  }),
);

Transports

TransportDescription
'console'Runtime-independent JSON lines (or pretty text) via console. Default.
'pino'Pino-backed, loaded via await import('pino') or injected factory. register() is async.
'noop'Discards all output. For tests or disabling logging.
'custom'Wraps LoggerPluginOptions.logger: ILogger with framework level filtering and redaction. Since 0.11.0.

LoggerPluginOptions.logger?: ILogger (since 0.11.0) is required with transport: 'custom' and refused with every other transport, including the default. Invalid combinations throw an Error named LoggerConfigurationError when constructing the plugin. The wrapper applies level (default info), error normalization, and the same redaction policy and redact paths as built-in transports before delegating metadata. Default secret patterns apply when redact is omitted; an explicit list replaces them. Children delegate to the supplied logger’s child() with redacted bindings and retain the wrapper. Request logging and trace correlation also apply. The supplied logger controls its own sink and may impose additional filtering.

Usage in Routes

import { CAPABILITIES, ILogger } from '@setu-ts/common';

app.router.get('/users/:id', async (ctx) => {
  const logger = ctx.services.get<ILogger>(CAPABILITIES.LOGGER);

  logger.info('Fetching user', { userId: ctx.params.id });

  const user = await getUser(ctx.params.id);

  logger.debug('User fetched', { userId: user.id });

  return ctx.response.json(user);
});

Trace correlation

When a CAPABILITIES.TELEMETRY provider is registered, every record the plugin’s logger emits carries the active span’s identifiers:

FieldValue
trace_idThe active span’s 32-hex trace identifier
span_idThe active span’s 16-hex span identifier

The names are snake_case, departing from this framework’s camelCase metadata convention (requestId), and the departure is deliberate: these fields exist to be read by a log backend, and the OpenTelemetry log-correlation convention that Loki, Elastic and the collector’s own processors key on is snake_case. A camelCase spelling would be internally consistent and would join up in none of the tools the correlation exists for.

Enrichment applies to every level and to child loggers, so logger.child({ requestId }) — what the request-logging middleware does — still carries them. A caller’s own trace_id in the metadata wins over the decorator’s.

Without a telemetry capability the record is byte-identical to before, and so it is when no span is active, when the telemetry service does not implement activeSpanContext, or when reading it throws. The read is guarded end to end: an observability enrichment must never turn logging into the fault.

The plugin does NOT declare CAPABILITIES.TELEMETRY in optionalDependencies. TelemetryPlugin already declares CAPABILITIES.LOGGER in its own, and the two edges together are a cycle the kernel’s plugin resolver refuses at start(). The telemetry capability is therefore resolved at CALL time, which is required rather than merely prudent: the resolver orders LoggerPlugin first, so telemetry is guaranteed absent when the logger registers.

One consequence is visible: the value registered under CAPABILITIES.LOGGER is an internal decorator, not the transport instance, so instanceof ConsoleLogger on the resolved capability no longer holds. ILogger is the contract; the concrete class was never part of it.

Child Loggers

app.middleware.add(async (ctx, next) => {
  const logger = ctx.services.get<ILogger>(CAPABILITIES.LOGGER);
  const requestLogger = logger.child({
    requestId: ctx.id,
    correlationId: ctx.request.headers.get('x-correlation-id') ?? undefined,
  });

  ctx.services.register(CAPABILITIES.LOGGER, requestLogger, { override: true });
  await next();
});

Logger Interface

interface ILogger {
  readonly level: LogLevel;
  fatal(message: string, metadata?: LogMetadata): void;
  error(message: string, metadata?: LogMetadata): void;
  warn(message: string, metadata?: LogMetadata): void;
  info(message: string, metadata?: LogMetadata): void;
  debug(message: string, metadata?: LogMetadata): void;
  trace(message: string, metadata?: LogMetadata): void;
  child(bindings: LogMetadata): ILogger;
}

type LogMetadata = Readonly<Record<string, unknown>>;
type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';

Standalone Logger Implementations

The logger implementations can be used directly without the plugin, e.g. in tests or scripts:

import { ConsoleLogger, NoopLogger, PinoLogger } from '@setu-ts/logger-plugin';

const consoleLogger = new ConsoleLogger(runtime, { level: 'debug', pretty: true });
const noopLogger = new NoopLogger();
// PinoLogger uses async construction (import('pino') is async):
const pinoLogger = await PinoLogger.create({ level: 'info', redact: ['password'] });

Request Logging Middleware

import { createRequestLoggerMiddleware } from '@setu-ts/logger-plugin';

app.middleware.add(
  createRequestLoggerMiddleware({
    slowRequestThreshold: 1000,
    // A string is an EXACT path match; a RegExp is tested against the path (M90a).
    excludePaths: ['/health', /^\/internal\//],
  }),
);

The middleware resolves CAPABILITIES.LOGGER on each request, creates a child logger bound to requestId, and logs:

  • request received at debug (method, path — the concrete path as the client sent it, not a route template). Set level: 'debug' to emit this arrival entry; its metadata is not built when the child’s exposed level excludes debug. With no readable level, the middleware calls debug() and lets the logger filter.
  • One request completed at info (method, path, status, duration in ms), including a request that threw: the status is the one errorHandler’s responder predicts through statusFor (500 without it), so a custom respond hook that chooses another status is not reflected
  • Slow request warning when duration exceeds slowRequestThreshold

It does not log the error itself; errorHandler logs it once (V9-80). The database-plugin scrub covers database errors only, so an identifier in a path segment reaches the log; keep it out with excludePaths, a redaction field redactor for path, or redact (which replaces the default secret patterns, so re-list them).


Agent card · TypeDoc API reference