HTTP Logger
Structured Pino-based request logger with correlation IDs and async-local context.
exjs-controllers ships a Pino-backed HTTP logger that:
- Emits one structured event per request when the response closes.
- Generates (or reuses) a correlation ID and echoes it via the
x-correlation-idresponse header. - Stores a child logger in an
AsyncLocalStoragecontext so any code path within the request can log without passing the logger around.
Enabling
Pass logger to configureApplication. Use false to disable the middleware entirely.
await configureApplication(app, {
controllers,
logger: {} // defaults: JSON to stdout
})Options
interface HttpLoggerOptions {
logger?: Logger // bring your own pino instance
genCorrelationId?: () => string // default: randomUUID
correlationIdHeaderName?: string // default: 'x-correlation-id'
logFormat?: 'json' | 'pretty' // default: 'json'
levelFormat?: 'label' | 'number' // default: 'number'
stream?: LogStream // custom destination
filePath?: string // also append every line to a file
}Level format
By default Pino serializes the level as its numeric code ("level":30 for
info). Some log aggregators (e.g. Dokploy) detect severity from the text and
fall back to a default badge when they see a bare number, so every line shows up
as "debug". Set levelFormat: 'label' to emit the string label instead:
logger: { levelFormat: 'label' } // → "level":"info"levelFormat | Output | When to use |
|---|---|---|
'number' (default) | "level":30 | Native Pino behavior; consumers that map numeric levels |
'label' | "level":"info" | Aggregators that classify by the level string |
This only changes JSON output; the pretty formatter always prints the
uppercased label regardless.
Pretty output (development)
logger: { logFormat: 'pretty' }This decorates each line into a multi-block format with timestamp, level, correlation ID, trace IDs and the HTTP request summary.
Writing to disk
logger: {
logFormat: 'pretty',
filePath: 'logs/app.log' // appended; parent dir is created if missing
}When filePath is provided, output is fanned out to both the in-process stream and the file.
Bring your own Pino
import pino from 'pino'
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: ['req.headers.authorization']
})
await configureApplication(app, {
controllers,
logger: { logger }
})Correlation IDs
For every request the middleware:
- Reads the correlation ID from the first available header: the configured
correlationIdHeaderName,x-request-id, thenx-transaction-id. - Generates a new one (
randomUUID) if none is present. - Sets
x-correlation-idon the response so downstream callers can echo it back. - Creates a Pino child logger bound to
{ correlationId }and stores it inAsyncLocalStorage.
Logging from anywhere
Import the logger proxy. Inside a request handler it resolves to the request-scoped child logger; outside, it falls back to the process-wide base logger.
import { logger } from 'exjs-controllers/logging/logger'
logger.info({ userId: 'usr_123' }, 'User loaded')Helpers
import {
appendRequestLoggerBindings,
getCorrelationId
} from 'exjs-controllers/logging/logger'
// Attach extra structured fields to every subsequent log in this request.
appendRequestLoggerBindings({ tenantId: 'acme' })
// Read the active correlation ID.
const cid = getCorrelationId()What gets logged per request
{
"level": 30,
"time": 1716480000000,
"correlationId": "8e7e...",
"httpRequest": {
"requestMethod": "GET",
"requestUrl": "/users/42",
"requestSize": "0",
"status": "200",
"userAgent": "curl/8.6.0",
"remoteIp": "203.0.113.10",
"serverIp": "10.0.0.4",
"referrer": "-"
}
}requestSize falls back to content-length, then Buffer.byteLength(body) for string/object bodies, then 0.
remoteIp is the client IP, resolved in order: CF-Connecting-IP / True-Client-IP (set by edge proxies like Cloudflare, authoritative and not spoofable through the edge), then request.ip (which resolves X-Forwarded-For when trust proxy is enabled), then the first X-Forwarded-For hop, then request.socket.remoteAddress, then -. Behind a reverse proxy (Dokploy/Traefik, Nginx, etc.) without an edge header, enable trust proxy with the correct hop count so request.ip reflects the real client instead of the proxy.
serverIp comes from response.socket.localAddress (or request.ip as a fallback) — i.e. the local interface that handled the request.