Logging

Structured logs, scoped channels & request tracing

Nucleus ships with a structured logger that every internal subsystem writes through. The logging block tunes severity (level), which feature areas speak (scopes), how output is rendered (prettyPrint / colorize / includeCallerInfo), what gets redacted (redactKeys), and per-request access logging with end-to-end tracing (requests).

Severity filtering is the familiar threshold model. Scope filtering is the powerful part — because each log line is tagged with a dotted scope like authentication.login or entity.create, you can silence entire subsystems or zoom in on exactly one while debugging, without touching code. On top of that, every request gets a server-generated x-request-id that appears in the access log, error log and authorization decisions — grep one id and you have the request's whole story.

Severity level#

A single threshold. Any message below the configured level is dropped before it reaches a transport. Levels are ordered debug < info < warn < error < fatal, so level: "warn" keeps warnings, errors and fatals while discarding debug and info chatter.

level'debug' | 'info' | 'warn' | 'error' | 'fatal'Optional

The minimum severity that will be emitted. The root mode option drives the defaults: with mode: 'development' the level defaults to debug and output is pretty-printed, colorized and carries caller file:line; otherwise it defaults to info with compact single-line JSON.

  • debugEverything, including verbose internal tracing. Ideal locally, far too noisy for production.
  • infoLifecycle and notable events. The default and a sensible production baseline.
  • warnRecoverable problems and suspicious conditions only.
  • errorFailed operations only.
  • fatalProcess-threatening failures only.
Default"info" ("debug" when mode: "development")

Scope filtering#

Scopes are dotted identifiers — category.subcategory — attached to log lines across the framework. The scopes array is an allow-list: only messages whose scope matches an entry are shown. This is orthogonal to level, so you combine them (e.g. only warnings, and only from authentication).

Focused debugging config
1{2  "logging": {3    "level": "debug",4    "scopes": ["authentication.*", "middleware.auth"]5  }6}
scopesstring[]Optional

Allow-list of scopes to emit. Use "*" to enable everything (the default). Use a category wildcard like "authentication.*" to keep one subsystem. Provide exact scopes like "tenant.provision" for surgical focus. An empty array [] silences all scoped logs, leaving only unscoped framework messages.

Example: ["authentication.*", "tenant.provision", "entity.create"]

Default["*"]

Output formatting & redaction#

How emitted lines look and what never reaches them. All four default from the root mode option — set them explicitly to override.

prettyPrintbooleanOptional

Multi-line, human-readable output. When false, each entry is a single JSON line — the right choice for log aggregators.

Defaulttrue in development mode
colorizebooleanOptional

ANSI colors per level in console output.

Defaulttrue in development mode
includeCallerInfobooleanOptional

Attach the real call site (file:line, skipping logger-internal frames) to every entry. Costs a stack capture per line — leave it off in production.

Defaulttrue in development mode
redactKeysstring[]Optional

Extra context keys to mask as [REDACTED]. These EXTEND the built-in list (password, secret, token, apiKey, authorization, cookie, …) — the built-ins always apply. Matching is case-insensitive and recursive through nested context objects.

Example: ["customerTaxId", "iban"]

Default[]

Request logging & tracing#

Every request is assigned a server-generated x-request-id (inbound values are stripped, so ids can't be spoofed), echoed back as a response header and attached to the access log, error log and authorization-decision logs. The requests block controls the access log itself.

Production logging config
1{2  "logging": {3    "level": "info",4    "prettyPrint": false,5    "redactKeys": ["iban"],6    "requests": {7      "slowThresholdMs": 1000,8      "excludePaths": ["/health", "/metrics*"]9    }10  }11}
requests.enabledbooleanOptional

One completion line per request — ← METHOD /path STATUS (Nms) — with requestId, userId, authType, tenant, ip and durationMs, under scope middleware.request. Levels auto-select: 2xx/3xx → info, 4xx or slow → warn, 5xx → error.

Defaulttrue
requests.logArrivalbooleanOptional

Also emit a debug '→ METHOD /path' line when the request arrives — useful for spotting requests that never complete.

Defaultfalse
requests.includeQuerybooleanOptional

Include the query string in the logged path.

Defaulttrue
requests.slowThresholdMsnumberOptional

Requests slower than this log at warn with slow: true, regardless of status.

Default3000
requests.excludePathsstring[]Optional

Paths to skip — exact matches or trailing-* prefixes like "/metrics*". Keeps probes and scrapers out of your logs.

Default["/health"]

Under the hood — the Logger#

A single Logger instance (exported as logger, also Logger.getInstance()) backs every subsystem. It is a structured logger with pluggable transports, request correlation and built-in audit — the two config dials above are the tip of it.

scoped(scope)ScopedLoggerOptional

How the scope tags above are produced: a subsystem calls logger.scoped('authentication.login') and every line it emits carries that scope. If the scope isn't in your allow-list the calls become genuine no-ops — filtering happens at the source, not just at output, so disabled scopes cost almost nothing.

child(context) / withCorrelationId(id)LoggerOptional

Per-request child loggers carry a correlation id (shown as the first 8 chars in pretty output) and a merged context object, so every line from one request is traceable. Context is passed through redactSensitiveData first, so configured secret keys never reach a transport.

transportsConsoleTransport · BufferedTransport · DatabaseAuditTransportOptional

Console output follows prettyPrint/colorize/includeCallerInfo (defaulted from mode, overridable per key). BufferedTransport batches entries (flush at 100 or every 1s). Audit is part of the logger: a DatabaseAuditTransport persists audit entries to your audit table — addTransport/addAuditTransport let you attach more sinks.

request() / db() / time() / timeAsync()helpersOptional

Convenience emitters used across the framework: request() logs method/path/status and auto-selects level by status (5xx→error, 4xx→warn, else info); db() logs a query with table + duration + row count; time()/timeAsync() measure and log an operation's duration.

audit() / trace()audit bridgeOptional

The same logger writes the immutable audit trail (see the Audit section). trace() can both log and conditionally write an audit entry in one call, gated by auditEnabled — which is why audit and logging share this one component.

Related sections