@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.
| Option | Type | Default | Description |
|---|---|---|---|
level | LogLevel | 'info' | Minimum level to emit. |
transport | LoggerTransport | 'console' | Implementation: 'console', 'pino', 'noop', or 'custom'. |
logger | ILogger | — | Required only with 'custom'; refused with other transports. Since 0.11.0. |
pretty | boolean | false | Pretty-print entries (console transport only). |
redact | readonly string[] | Secret-field patterns | Dot-paths to redact from metadata; pass [] to opt out of the secure defaults. |
redaction | RedactionPolicy | IRedactionService | — | Shared policy or service applied before log egress. |
requestLogging | boolean | false | Register request/response logging middleware. |
slowRequestThreshold | number | 5000 | Requests slower than this (ms) log at warn. |
excludePaths | readonly PathPattern[] | [] | Paths excluded from request logging. A string is an EXACT match; a RegExp is tested against the path. |
pinoFactory | PinoFactory | — | 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.LOGGERis an internal decorator wrapping the configured transport, soinstanceof ConsoleLoggeron the resolved capability does not hold.ILoggeris 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
| Export | Kind |
|---|---|
createRequestLoggerMiddleware | function |
LoggerPlugin | function |
ConsoleLogger | class |
NoopLogger | class |
PinoLogger | class |
ConsoleLoggerOptions | interface |
LoggerPluginOptions | interface |
NoopLoggerOptions | interface |
PinoLoggerOptions | interface |
RequestLoggerOptions | interface |
LoggerTransport | type |
PinoFactory | type |
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.
| Option | Type | Consumer |
|---|---|---|
LoggerPluginOptions.excludePaths | readonly 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
| Transport | Description |
|---|---|
'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:
| Field | Value |
|---|---|
trace_id | The active span’s 32-hex trace identifier |
span_id | The 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.TELEMETRYinoptionalDependencies.TelemetryPluginalready declaresCAPABILITIES.LOGGERin its own, and the two edges together are a cycle the kernel’s plugin resolver refuses atstart(). The telemetry capability is therefore resolved at CALL time, which is required rather than merely prudent: the resolver ordersLoggerPluginfirst, so telemetry is guaranteed absent when the logger registers.One consequence is visible: the value registered under
CAPABILITIES.LOGGERis an internal decorator, not the transport instance, soinstanceof ConsoleLoggeron the resolved capability no longer holds.ILoggeris 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 receivedatdebug(method, path — the concrete path as the client sent it, not a route template). Setlevel: '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 callsdebug()and lets the logger filter.- One
request completedatinfo(method, path, status, duration in ms), including a request that threw: the status is the oneerrorHandler’s responder predicts throughstatusFor(500without it), so a customrespondhook 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).