exjs-controllers

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-id response header.
  • Stores a child logger in an AsyncLocalStorage context 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

exjs-controllers/logging/httpLogger
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"
levelFormatOutputWhen to use
'number' (default)"level":30Native 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:

  1. Reads the correlation ID from the first available header: the configured correlationIdHeaderName, x-request-id, then x-transaction-id.
  2. Generates a new one (randomUUID) if none is present.
  3. Sets x-correlation-id on the response so downstream callers can echo it back.
  4. Creates a Pino child logger bound to { correlationId } and stores it in AsyncLocalStorage.

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.

On this page