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()addsMachineName,ProcessIdandEnvironment(fromNODE_ENV).fileSinkappends one JSON line per event with synchronous writes — nothing is lost if the process dies — and startslogs/app-20261008.logat midnight, keeping fourteen files. Options in Sinks.closeOnExitflushes and closes onbeforeExit, SIGINT and SIGTERM, and logs an uncaught exception or unhandled rejection atfatalbefore 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 msat error for a 5xx, info otherwise. It also:
- puts
RequestId,RequestMethodandRequestPathin the ambient context for everything logged while handling the request — your ownlog.info()calls included, not justreq.log; - sets
req.log, a logger bound to the request id; - writes the id back as
x-request-id(the header is configurable, orfalse); - reads an incoming
x-request-id(orx-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_7Every 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');