Guides

Migrating from pino

The API side by side, and a pino-shaped JSON output.

pino writes JSON fast and leaves structure to you. logit keeps the JSON and adds the structure: the message is a template, values keep their names, and the output format is one of several.

Side by side#

pinologit
pino()createLogger() — or just import { log }
logger.info('hello %s', name)log.info('hello {Name}', name)
logger.info({ userId }, 'signed in')log.info('User {UserId} signed in', userId) — or, during migration, log.info({ userId }, 'signed in') works as is
logger.error(err, 'failed')log.error(err, 'failed') — the same
logger.child({ requestId })log.forContext({ requestId }) (or log.child(), an alias)
logger.child({ name: 'db' })log.forSource('app.db') — a source, with per-source levels
level: 'debug'minimumLevel: 'debug'
logger.level = 'debug'a LevelSwitch as the minimum, then levelSwitch.level = 'debug'
logger.isLevelEnabled('debug')log.isEnabled('debug')
trace / fatalverbose / fatal ('trace' is accepted as a level name)
redact: ['password', 'headers.authorization']destructuring: { redact: ['password', 'headers.authorization'] } — bare keys match at any depth
serializers: { user: … }destructuring: { policies: [byTransforming(User, …)] }
base: { pid, hostname }withProcessInfo() from /node
formatters.leveljsonFormatter({ levelFormat })
timestamp: pino.stdTimeFunctions.isoTimeISO 8601 is the default; timestampFormat: 'epoch' for pino's number
transport: { target: 'pino-pretty' }consoleSink() — pretty on a terminal by itself
pino.transport(…), pino.destination(file)sinks: fileSink, httpSink, seqSink, otlpHttpSink, your own
pino-httprequestLogger() from /express, withLogging() from /next
pino.final, logger.flush()await log.close(), closeOnExit(log)
mixin()enrichers

The same JSON shape#

A pipeline tuned to pino's lines keeps working with a remapped JSON formatter:

import { configure, consoleSink, jsonFormatter } from '@unhingged/logit';
import { withProcessInfo } from '@unhingged/logit/node';

configure({
  enrichers: [withProcessInfo()],
  sinks: [consoleSink({ format: jsonFormatter({ keys: { timestamp: 'time', message: 'msg' }, levelFormat: 'number', timestampFormat: 'epoch', template: false }) })],
});
// {"time":1791... ,"level":30,"msg":"User 42 signed in","UserId":42,"MachineName":"…","ProcessId":1234}

Rename MachineName / ProcessId to pino's hostname / pid with withProperty('hostname', os.hostname()) instead of withProcessInfo() if the exact keys matter.

What changes in your code#

  1. %s / %d / %o become named holes. logger.info('user %s bought %d', name, n) → log.info('User {Name} bought {Count}', name, n). The event now carries Name and Count.
  2. The leading object becomes template holes where the values belong to the message, and forContext where they belong to the logger. log.info({ userId }, 'msg') keeps working in the meantime.
  3. child({ name }) becomes forSource — and the source is what overrides match on.
  4. Transports become sinks. There is no worker thread (on the roadmap); the batching sinks keep network I/O off the hot path, the file sink is synchronous by design.

Performance#

pino is faster at raw JSON throughput; it has no template to parse and no values to capture. logit parses each template once (cached), checks the level before doing anything, and captures values with a bounded walk — for a service doing thousands of events a second the difference is not where the time goes. If an extreme hot loop logs, guard it with isEnabled().