Getting started

Quick start

The default logger, a template, a level, a source, a context — in five minutes.

The default logger#

Import log and write. Until you configure anything it writes info and above to the console — pretty on a terminal, JSON when stdout is a pipe (a container, Vercel, a CI runner).

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

log.info('Server listening on {Port}', 3000);
// 08:12:03.123 INF Server listening on 3000

The braces are message templates, not string interpolation. {Port} names the value, so the event stores Port: 3000 next to the template — and a JSON sink writes both:

{"timestamp":"2026-10-08T08:12:03.123Z","level":"info","message":"Server listening on 3000","template":"Server listening on {Port}","Port":3000}

Levels#

Six, in order: verbose, debug, info, warn, error, fatal. One method each.

log.verbose('Cache miss for {Key}', key);
log.debug('Query plan: {@Plan}', plan);
log.info('User {UserId} signed in', user.id);
log.warn('Retrying {Attempt} of {Max}', attempt, max);
log.error(err, 'Payment {PaymentId} failed', payment.id);
log.fatal(err, 'Could not bind to {Port}', port);

An error goes first, before the template. It is kept apart from the properties, with its stack and its cause chain, and every formatter knows how to render it. See Errors.

Structured values#

An object passed to a hole is captured whole:

log.info('Order {OrderId} shipped to {@Address}', 1042, { city: 'Budapest', zip: '1011' });

Plain objects and arrays are data and come through without an operator. @ is for class instances — a User, a URL, an ORM entity — which are otherwise rendered with their own toString() or class name, so you never dump a database client by accident. $ forces a string. The full rules are in Structured data.

Sources#

forSource() returns a logger whose events carry a SourceContext. Dotted names nest, and the minimum level can be set per prefix:

const dbLog = log.forSource('app.db');
dbLog.debug('Query took {Elapsed:0.0} ms', 12.34);
// 08:12:03.456 DBG [app.db] Query took 12.3 ms

Bound properties#

forContext() binds properties to a logger for the rest of its life:

const tenantLog = log.forContext({ tenantId: 'acme' });
tenantLog.info('Syncing {Count} records', 120);
// … "tenantId":"acme","Count":120

Ambient context#

LogContext.run() makes properties ambient for everything logged inside — across await, however deep:

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

await LogContext.run({ requestId: 'req_9f3a' }, async () => {
  await handle(request); // every event written in here carries requestId
});

With TypeScript 5.2+ there is also using _ = LogContext.push({ userId }), which pops when the block ends. See Enrichment and context.

Configuring the default logger#

configure() reconfigures log in place — and every logger derived from it, including the forSource() ones already handed out:

import { configure, consoleSink, withProperty } from '@unhingged/logit';

configure({
  minimumLevel: 'debug',
  overrides: { 'app.db': 'verbose', next: 'warn' },
  enrichers: [withProperty('Application', 'shop')],
  sinks: [consoleSink({ format: 'json' })],
});

Or create an independent logger with the same options through createLogger(), or with Serilog's fluent builder:

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

const logger = new LoggerConfiguration()
  .minimumLevel.debug()
  .minimumLevel.override('next', 'warn')
  .enrich.withProperty('Application', 'shop')
  .writeTo.console()
  .createLogger();

Every option is described in Configuration. LOGIT_LEVEL=debug in the environment sets the minimum without touching code.

Closing#

Sinks that buffer — HTTP, Seq, OTLP — send in batches. Flush them before the process ends:

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

await closeAndFlush(); // the global logger; `await logger.close()` for your own

In Node, closeOnExit(log) from @unhingged/logit/node does this on beforeExit, SIGINT and SIGTERM, and logs crashes first. See Production.

Where next#