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#
| pino | logit |
|---|---|
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 / fatal | verbose / 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.level | jsonFormatter({ levelFormat }) |
timestamp: pino.stdTimeFunctions.isoTime | ISO 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-http | requestLogger() 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#
%s/%d/%obecome named holes.logger.info('user %s bought %d', name, n)→log.info('User {Name} bought {Count}', name, n). The event now carriesNameandCount.- The leading object becomes template holes where the values belong to the message, and
forContextwhere they belong to the logger.log.info({ userId }, 'msg')keeps working in the meantime. child({ name })becomesforSource— and the source is whatoverridesmatch on.- 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().