Production
JSON on stdout, environment variables, flushing, containers, Vercel and serverless.
JSON on stdout#
The default console sink writes JSON when stdout is not a terminal, pretty when it is. That is the right default for containers, PaaSes and CI: one JSON object per line, every property at the root, picked up by whatever tails the process. Say it explicitly when you prefer:
configure({ sinks: [consoleSink({ format: 'json' })] });Datadog, Loki, CloudWatch and Vercel read level and message as is. For a pipeline that wants other names, jsonFormatter({ keys }).
Environment variables#
LOGIT_LEVEL=debug— the minimum level, read when a logger is created orconfigure()d. An unreadable value is ignored with a selfLog note.LOGIT_OVERRIDES=next=warn,app.db=verbose— per-source levels; explicitoverrideswin per key.NO_COLOR,FORCE_COLOR— colour in the pretty format.
All in Environment variables.
Flush and close#
Console and file sinks write as they go. HTTP, Seq and OTLP sinks batch, and their timers never keep the process alive, so the process must close them:
import { closeAndFlush, log } from '@unhingged/logit';
await log.flush(); // send what is queued, keep going
await closeAndFlush(); // the global logger: flush, then close every sinkIn Node, register once at startup and forget about it:
import { closeOnExit } from '@unhingged/logit/node';
closeOnExit(log);| Option | Default | |
|---|---|---|
signals | ['SIGINT', 'SIGTERM'] | handled: close, then exit with 128 + the signal number |
exit | true | exit after closing; false to leave that to you |
crashes | true | uncaughtException / unhandledRejection are logged at fatal, sinks are closed, the process exits 1 |
It also hooks beforeExit, for the normal case. It returns a function that removes every handler.
A framework that handles signals itself (NestJS, some process managers): closeOnExit(log, { signals: [], crashes: false }) keeps only beforeExit, and you call close() from the framework's shutdown hook.
Containers#
- Log to stdout; let the runtime collect. A file sink inside a container is a file nobody reads.
STOPSIGNAL SIGTERM(Docker's default) reachescloseOnExit; give the container a few seconds of grace so the last batch leaves.- Set
LOGIT_LEVELper environment instead of rebuilding.
Vercel and serverless#
- The console sink writes JSON (no TTY), which Vercel parses: the level and the message show in the dashboard, properties are searchable.
- An instance can be frozen the moment a response is sent, so a batching sink may never get its timer. Either send synchronously — the console is — or flush before returning:
await log.flush()at the end of a long handler.createOnRequestError()flushes after logging an error for exactly this reason. - The filesystem is ephemeral: no
fileSink. x-vercel-idis read as the request id, so a log line can be matched to Vercel's own request log.
More in Next.js.
selfLog#
A sink that fails must not take the application down, so failures are swallowed — into selfLog, the logger's own diagnostics, which is off by default:
import { selfLog } from '@unhingged/logit';
selfLog.enable('stderr'); // or a function (line) => …
selfLog.enable((line) => metrics.increment('log.selflog'));What goes there: a sink that threw or failed to flush, a batch given up on after retries, a full queue, an enricher that threw, an unreadable LOGIT_LEVEL. Enable it in staging and whenever a sink is new; read it when logs seem to go missing. onSinkError in the options receives sink failures instead, if you prefer a callback per logger.
Sizing the queue#
A batching sink holds maxQueue events (default 10 000) while the endpoint is down, then drops the oldest. At a few hundred bytes per event that is a few megabytes — raise it for bursty workloads, lower it on small instances. onError sees every batch that is given up on, with its events, so you can spill them to disk. A durable, disk-backed queue is on the roadmap.
Throughput#
scripts/bench.mjs in the repository writes JSON to a null stream from logit, pino and winston, so only the library's own work is measured. Node 20, 200 000 events each, an Apple Silicon laptop, 2026-10-08:
| logit | pino | winston | |
|---|---|---|---|
info('Order {OrderId} shipped', id) | 1.48 M events/s | 1.91 M | 0.78 M |
…with a nested object and {Elapsed:0.0} | 0.59 M events/s | 1.11 M | 0.54 M |
| Capture only, null sink | 3.2 M events/s | ||
| Level disabled | 33 M calls/s |
pino is faster because it does less per event: no template rendering, no capture rules. logit's capture is cheap — the formatting path is where the difference lives — and either library is far ahead of what a file, a socket or an HTTP endpoint absorbs. isEnabled() is the cheap check for anything expensive to build; a disabled level costs 30 ns.
Checklist#
- JSON to stdout,
LOGIT_LEVELfrom the environment closeOnExit(log)(Node) orawait closeAndFlush()at the endredactfor the keys that must never be logged (Redaction)- overrides for the framework's noise:
{ next: 'warn' } selfLog.enable('stderr')while anything is new