Keelv0.86.0
Docs / Logger

A small leveled logger. It writes structured JSON by default — one line per event, ready for log aggregators — and pretty single-line output in debug. Reach it with the global logger() helper.

Logging

import { logger } from "@shaferllc/keel/core";

logger().info("user registered", { userId: user.id });
logger().warn("cache miss", { key });
logger().error("payment failed", { orderId, error: String(err) });
logger().debug("query", { sql, ms });

The second argument is structured context — it's merged into the log line, not string-concatenated, so it stays queryable:

{"level":"info","time":"2026-07-10T…","msg":"user registered","userId":42}

Every line carries three reserved keys — level, time (an ISO-8601 stamp), and msg — followed by any bound fields and then the call's context. Context is spread last, so a context key of level, time, or msg overwrites the reserved field; steer clear of those names in your payloads.

Levels

trace < debug < info < warn < error < fatal. Only events at or above the configured level are emitted. Set the threshold via config:

// config/logger.ts
export default { level: env("LOG_LEVEL", "info") };

Under the hood the levels are ordinal (trace 10, debug 20, info 30, warn 40, error 50, fatal 60); a line is dropped when its level sits below the threshold. The default threshold is "info", so debug and trace stay silent until you lower it.

log(level, message, context?) takes the level at runtime, when it isn't known statically.

Pretty output turns on automatically when app.debug is true. In pretty mode each event is a single human-readable line — [2026-07-10T…] INFO user registered {"userId":42} — and the writer routes by level: warn goes to console.warn, error and fatal to console.error, everything else to console.log. In JSON mode every level is written to console.log.

enabled: false silences a logger entirely, at every level.

Don't pay for lines you won't emit

The threshold drops the line, but the context object is built either way — so an expensive snapshot costs you even when nobody sees it. Gate it:

if (logger().isLevelEnabled("debug")) {
  logger().debug("state", { snapshot: expensiveSnapshot() });
}

// ...or the callback form
logger().ifLevelEnabled("debug", (log) => log.debug("state", { snapshot: expensiveSnapshot() }));

Where the lines go

A sink is where log records land. The default writes to the console (JSON, or pretty), but it's just a function, so logs can go anywhere — a file, an HTTP collector, a buffer:

import { Logger, type Sink } from "@shaferllc/keel/core";

const httpSink: Sink = (record) => {
  void fetch("https://logs.example.com", { method: "POST", body: JSON.stringify(record) });
};

new Logger({ sink: httpSink });

A sink receives the structured LogRecord{ level, time, msg, fields } — not a formatted string, so it can do what it likes with the shape. fields is already redacted.

MemorySink collects records in memory, which is what you want in a test:

import { Logger, MemorySink } from "@shaferllc/keel/core";

const sink = new MemorySink();
const log = new Logger({ level: "trace", sink: sink.sink });

log.info("hello", { userId: 1 });

sink.messages(); // ["hello"]
sink.at("info"); // the records at one level
sink.records[0].fields; // { userId: 1 }
sink.clear();

Named loggers

Give a subsystem its own level or destination:

import { setLogger, namedLogger, Logger } from "@shaferllc/keel/core";

setLogger(new Logger({ level: "trace", sink: auditSink }), "audit");

namedLogger("audit").trace("permission granted", { userId });

The application's own logger stays where it is — reach that with logger().

Child loggers

Bind fields once (a request id, a job name) and they appear on every line:

const log = logger().child({ requestId: request.header("x-request-id") });
log.info("handling");   // includes requestId
log.info("done");        // includes requestId

A child inherits its parent's level and pretty settings and merges its bindings on top of the parent's — so you can nest them, and a child's field wins over a parent's field of the same name. The parent is untouched; child() returns a fresh Logger.

const base = logger().child({ service: "billing" });
const job = base.child({ jobId });   // { service, jobId } on every line

Standing up a logger yourself

The framework binds one Logger for you, but the class is a plain object you can construct directly — handy in a script or a test:

import { Logger } from "@shaferllc/keel/core";

const log = new Logger({ level: "debug", pretty: true, bindings: { env: "dev" } });
log.debug("boot", { pid: 1 });

With no options it defaults to level: "info", pretty: false, and no bindings.

Per-request logging

requestLogger() is a built-in middleware that binds a child logger with a generated reqId to each request, so every log line within a request correlates. Install it in your HTTP kernel, then reach the request's logger anywhere with requestLog():

import { requestLogger, requestLog } from "@shaferllc/keel/core";

// app/Http/Kernel.ts
kernel.use(requestLogger());

// anywhere in the request — the line carries this request's reqId:
requestLog().info("charging card", { orderId });

By default it also logs the request start and completion:

{"level":"info","time":"…","msg":"request","reqId":"…","method":"GET","path":"/orders"}
{"level":"info","time":"…","msg":"request completed","reqId":"…","status":200,"ms":12.4}

Options: genReqId(c) to control id generation, idHeader to reuse an incoming id (e.g. "x-request-id" for distributed tracing), and logRequests: false to skip the automatic start/completion lines. Outside a request (or without the middleware), requestLog() falls back to the base logger().

Redaction

Keep secrets out of your logs with redact — top-level keys or dot paths. Matched values are replaced with "[redacted]"; the original object is never mutated, so redacting doesn't corrupt the data you're still using:

const log = new Logger({
  redact: ["password", "req.headers.authorization"],
});

log.info("login", { user: "ada", password: "s3cret", req: { headers: { authorization: "Bearer x" } } });
// {"level":"info",…,"user":"ada","password":"[redacted]","req":{"headers":{"authorization":"[redacted]"}}}

A * segment matches every key at that level — which is how you catch a secret that appears under a key you don't know in advance:

new Logger({ redact: ["*.password", "creds.*.token"] });

log.info("audit", {
  alice: { password: "a", name: "Alice" },
  bob: { password: "b", name: "Bob" },
});
// both passwords redacted; both names kept

Pass an object instead of an array to change the placeholder, or drop the key outright:

new Logger({ redact: { paths: ["password"], censor: "***" } });
new Logger({ redact: { paths: ["password"], remove: true } }); // the key disappears

Redaction is inherited by child loggers, so a redacting base logger keeps per-request loggers safe too, and it runs before the sink — a custom sink can never see the unredacted values.


API reference

logger()

logger(): Logger

Resolves the application's shared Logger from the container.

import { logger } from "@shaferllc/keel/core";

logger().info("ready");

Notes: a global helper — no need to thread the logger through your call stack. Throws No Keel application has been bootstrapped… if called before an Application exists. Returns the same singleton every call, so child() off it when you want per-request bindings rather than mutating the shared instance.

Logger

The logger itself. The framework binds one for you (reach it with logger()), but you can also new Logger(options) directly.

new Logger(options?)

new Logger(options?: LoggerOptions): Logger

Creates a logger with the given level, format, and bound fields.

const log = new Logger({ level: "warn", pretty: true });

Notes: options defaults to {}, which resolves to level: "info", pretty: false, no bindings. The level is captured at construction — there is no setter, so change it by creating a new logger.

debug(message, context?)

debug(message: string, context?: Record<string, unknown>): void

Logs at the debug level — the noisiest, off by default.

log.debug("cache lookup", { key });

Notes: suppressed unless the threshold is "debug". context is optional and merged into the line after the bound fields.

info(message, context?)

info(message: string, context?: Record<string, unknown>): void

Logs at the info level — the default threshold.

log.info("user registered", { userId: 42 });

warn(message, context?)

warn(message: string, context?: Record<string, unknown>): void

Logs at the warn level.

log.warn("cache miss", { key });

Notes: in pretty mode this routes to console.warn; in JSON mode, like every level, to console.log.

error(message, context?)

error(message: string, context?: Record<string, unknown>): void

Logs at the error level — the highest, always emitted.

log.error("payment failed", { orderId, error: String(err) });

Notes: in pretty mode this routes to console.error. It does not throw or capture stack traces for you — serialize an Error into context yourself (e.g. error: String(err) or err.stack).

child(bindings)

child(bindings: Record<string, unknown>): Logger

Returns a new logger that carries bindings on every line, in addition to the parent's.

const reqLog = logger().child({ requestId });
reqLog.info("handling");   // line includes requestId

Notes: inherits the parent's level and pretty; merges bindings over the parent's (child wins on key collisions). Chainable — call child() on a child. The parent is not modified.

requestLogger(options?)

requestLogger(options?: RequestLoggerOptions): MiddlewareHandler

Middleware that binds a reqId child logger to each request and (by default) logs the request start and completion.

kernel.use(requestLogger({ idHeader: "x-request-id" }));

Notes: options — genReqId(c) (default crypto.randomUUID()), idHeader (reuse an incoming id), logRequests (default true).

requestLog()

requestLog(): Logger

The current request's child logger (carrying its reqId), or the base logger() outside a request / without the middleware installed.

requestLog().info("charging card"); // line carries reqId

Interfaces & types

LoggerOptions

interface LoggerOptions {
  level?: LogLevel;                     // minimum level to emit; default "info"
  pretty?: boolean;                     // single-line human output; default false
  bindings?: Record<string, unknown>;   // fields merged into every line
}

Passed to new Logger() (and carried forward by child()). Use it to set the threshold, switch to pretty output, and attach ambient fields.

const log = new Logger({ level: "debug", pretty: true, bindings: { app: "api" } });

LogLevel

type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"

The four severity levels, in ascending order. Used for the level option and selected implicitly by each method.

const threshold: LogLevel = "warn";
new Logger({ level: threshold });

isLevelEnabled(level) / ifLevelEnabled(level, fn)

isLevelEnabled(level: LogLevel): boolean — whether a level would be emitted. Check it before building an expensive context object.

ifLevelEnabled(level: LogLevel, fn: (log: Logger) => void): void — the callback form.

log(level, message, context?)

log(level: LogLevel, message: string, context?: Record<string, unknown>): void — log at a level chosen at runtime.

consoleSink(pretty?)

consoleSink(pretty = false): Sink — the default sink. JSON to stdout, or a pretty single line.

MemorySink

Collects records in memory — for tests.

Member Signature
sink Sink — hand this to LoggerOptions.sink
records LogRecord[]
at (level) => LogRecord[]
messages () => string[]
clear () => void

setLogger(logger, name) / namedLogger(name)

Register a logger under a name, and resolve it. namedLogger throws for an unknown name.

Interfaces & types (added)

Sink

type Sink = (record: LogRecord) => void — where log lines go.

LogRecord

{ level: LogLevel; time: string; msg: string; fields: Record<string, unknown> }fields is already redacted.

RedactOptions

{ paths: string[]; censor?: string; remove?: boolean } — a * path segment matches every key at that level. LoggerOptions.redact also accepts a bare string[].