Getting started

Node and Express

A logger with a rolling file, request logging middleware, and a clean exit.

Everything Node-specific is in @unhingged/logit/node; Express middleware is in @unhingged/logit/express. Both re-export the root entry.

A production logger#

// lib/log.ts
import { createLogger, consoleSink, fileSink, withProcessInfo, closeOnExit } from '@unhingged/logit/node';

export const log = createLogger({
  minimumLevel: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
  overrides: { 'app.db': 'verbose' },
  enrichers: [withProcessInfo()],
  destructuring: { redact: ['password', 'authorization', '*.token'] },
  sinks: [
    consoleSink(),
    fileSink({ path: 'logs/app.log', rolling: 'day', retainedFileCount: 14 }),
  ],
});

closeOnExit(log);
  • withProcessInfo() adds MachineName, ProcessId and Environment (from NODE_ENV).
  • fileSink appends one JSON line per event with synchronous writes — nothing is lost if the process dies — and starts logs/app-20261008.log at midnight, keeping fourteen files. Options in Sinks.
  • closeOnExit flushes and closes on beforeExit, SIGINT and SIGTERM, and logs an uncaught exception or unhandled rejection at fatal before exiting. See Production.

Or configure the global log the same way with configure() and import it everywhere.

Express#

import express from 'express';
import { requestLogger, errorLogger } from '@unhingged/logit/express';
import { log } from './lib/log';

const app = express();

app.use(requestLogger({ logger: log, ignore: (req) => req.path === '/health' }));

app.get('/orders', async (req, res) => {
  req.log.info('Listing orders for {CustomerId}', req.query.customer); // carries RequestId
  res.json(await listOrders());
});

app.use(errorLogger({ logger: log }));
app.listen(3000, () => log.info('Listening on {Port}', 3000));

requestLogger writes one completion event per request when the response finishes:

HTTP GET /orders responded 200 in 14.2300 ms

at error for a 5xx, info otherwise. It also:

  • puts RequestId, RequestMethod and RequestPath in the ambient context for everything logged while handling the request — your own log.info() calls included, not just req.log;
  • sets req.log, a logger bound to the request id;
  • writes the id back as x-request-id (the header is configurable, or false);
  • reads an incoming x-request-id (or x-vercel-id) first, so a load balancer's id survives.

errorLogger logs whatever reaches it with the request's context and calls next(error), so your own error handler still answers. Mount it after the routes.

Adding facts to the completion event#

Anywhere inside a request, diagnosticContext.set() adds a property to that request's completion event — one place for "which user, which tenant, how many rows" instead of a line per fact:

import { diagnosticContext } from '@unhingged/logit';

app.get('/orders', async (req, res) => {
  const orders = await listOrders();
  diagnosticContext.set('OrderCount', orders.length);
  diagnosticContext.set('UserId', req.user?.id);
  res.json(orders);
});
// HTTP GET /orders responded 200 in 14.2300 ms OrderCount=12 UserId=u_7

Every option — the message template, getLevel, enrich, ignore, the id header — is in Request logging.

Plain Node servers, workers, scripts#

There is nothing to wire for a script: import log, write, and await closeAndFlush() at the end if an HTTP or OTLP sink is configured. For a raw http.createServer, open a scope per request yourself:

import { LogContext, log } from '@unhingged/logit/node';
import { createServer } from 'node:http';

createServer((req, res) => {
  LogContext.run({ RequestId: crypto.randomUUID(), RequestPath: req.url }, () => handle(req, res));
}).listen(3000);

Workers (worker_threads) each get their own logger; configure it at the top of the worker file.

CommonJS#

The package ships CJS too:

const { log, consoleSink } = require('@unhingged/logit/node');