Guides

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 or configure()d. An unreadable value is ignored with a selfLog note.
  • LOGIT_OVERRIDES=next=warn,app.db=verbose — per-source levels; explicit overrides win 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 sink

In Node, register once at startup and forget about it:

import { closeOnExit } from '@unhingged/logit/node';
closeOnExit(log);
OptionDefault
signals['SIGINT', 'SIGTERM']handled: close, then exit with 128 + the signal number
exittrueexit after closing; false to leave that to you
crashestrueuncaughtException / 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) reaches closeOnExit; give the container a few seconds of grace so the last batch leaves.
  • Set LOGIT_LEVEL per 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-id is 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:

logitpinowinston
info('Order {OrderId} shipped', id)1.48 M events/s1.91 M0.78 M
…with a nested object and {Elapsed:0.0}0.59 M events/s1.11 M0.54 M
Capture only, null sink3.2 M events/s
Level disabled33 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_LEVEL from the environment
  • closeOnExit(log) (Node) or await closeAndFlush() at the end
  • redact for the keys that must never be logged (Redaction)
  • overrides for the framework's noise: { next: 'warn' }
  • selfLog.enable('stderr') while anything is new