Singleton-first structured JSON logger built on pino.
- Singleton by default —
require('util-logger')returns the same instance everywhere - Configurable stdout — stdout on by default; override with
destinations, silence withdestinations: [] - Custom async transports — subclass
AbstractTransportto fan out to any destination (main thread) - Worker-thread targets — use
targetsfor pino's native worker-thread transport mode - Native pino children —
logger.child(bindings)returns a real pino child logger - Standard.js — no semicolons, single quotes, 2-space indent
const logger = require('util-logger')
logger.info({ port: 3000 }, 'server started')
// {"level":30,"time":"<ISO>","port":3000,"msg":"server started"}
logger.warn({ percent: 92 }, 'disk usage high')
// {"level":40,"time":"<ISO>","percent":92,"msg":"disk usage high"}
logger.error(new Error('ECONNREFUSED'), 'connection refused')
// {"level":50,"time":"<ISO>","err":{"type":"Error","message":"ECONNREFUSED","stack":"..."},"msg":"connection refused"}Log methods: trace · debug · info · warn · error · fatal
Each method is a pure pass-through to the underlying pino instance:
logger.info(msg)
logger.info(obj, msg)
logger.info(obj) // obj.msg used as message
logger.error(err, msg) // pino serialises Error nativelyRebuilds the logger in place. Affects all future log calls on the singleton.
| Option | Type | Default | Description |
|---|---|---|---|
level |
string | 'info' |
Minimum log level (trace / debug / info / warn / error / fatal) |
label |
string | null | null |
Added as label field on every line; omitted when null |
transports |
Transport[] | [] |
Async transport instances (main-thread; see Custom transport) |
destinations |
object[] | null | null |
Stream destinations (see Destinations); null = default stdout |
targets |
object[] | null | null |
Pino worker-thread targets (see Worker-thread targets); mutually exclusive with transports |
logger.configure({ level: 'debug', label: 'auth-service' })
logger.debug('debug now visible')
// {"level":20,"time":"<ISO>","label":"auth-service","msg":"debug now visible"}Invalid level throws and leaves the previous configuration unchanged:
try {
logger.configure({ level: 'banana' })
} catch (err) {
// logger still at previous level/label/transports
}
configure()is a full replace. Every call resetsdestinations,transports, andtargetsto their defaults unless explicitly provided. If you omitdestinations, stdout is restored to default even if a previous call had changed it. UseaddTransport()to append a transport while preserving the current destination setup. Transports dropped by aconfigure()call haveclose()called on them automatically.
transports are always additive on top of whatever destinations are active. Only destinations controls whether and where stdout/stderr goes.
| Call | stdout? | notes |
|---|---|---|
| (initial / no configure) | ✅ default stdout | _destinations = null → auto stdout |
configure({ transports: [t] }) |
✅ default stdout + t | destinations omitted → stdout stays |
configure({ destinations: [{ level: 'debug', destination: 1 }] }) |
✅ explicit stdout | replaces default; same fd, but level-controlled |
configure({ destinations: [{ level: 'debug', destination: 1 }, { level: 'warn', destination: 2 }] }) |
✅ stdout (debug/info) + stderr (warn+) | dedupe routing; each entry goes to exactly one |
configure({ destinations: [], transports: [t] }) |
❌ silenced | [] → no destination streams; t is the only sink |
configure({ destinations: [] }) |
❌ silenced | nothing receives entries |
configure({ targets: [spec] }) |
✅ default stdout | auto-translated to pino/file target |
configure({ destinations: [{ level: 'warn', destination: 2 }], targets: [spec] }) |
❌ stderr only (not stdout) | explicit destinations replace default in targets mode too |
configure({ destinations: [], targets: [spec] }) |
❌ silenced | spec worker is the only sink |
addTransport(t) |
✅ preserved | does not touch _destinations |
addTarget(spec) |
✅ preserved | does not touch _destinations |
Full-replace caveat:
logger.configure({ destinations: [{ level: 'warn', destination: 2 }] })
// stderr only — stdout is gone
logger.configure({ transports: [t] })
// destinations not provided → reset to null → stdout IS BACK + t
// the stderr destination from the previous call is silently lostIf you need to add a transport without disturbing the current destination setup, use addTransport() instead of configure().
logger.child(bindings) returns a native pino child. Bindings are merged into every line the child produces.
logger.configure({ label: 'app' })
const child = logger.child({ module: 'auth', requestId: 'req-123' })
child.info('token validated')
// {"level":30,"time":"<ISO>","label":"app","module":"auth","requestId":"req-123","msg":"token validated"}
const grandchild = child.child({ handler: 'login' })
grandchild.info({ userId: 42 }, 'login attempt')
// {"level":30,"time":"<ISO>","label":"app","module":"auth","requestId":"req-123","handler":"login","userId":42,"msg":"login attempt"}Snapshot semantics — a child captures the pino stream at creation time. A subsequent configure() call rebuilds the parent's pino instance but does not affect existing children; they continue routing through the old stream.
logger.newInstance() creates an independent Logger pre-configured with the singleton's current settings (level, label, destinations, transports are copied; targets are not). Changes to either instance do not affect the other.
logger.configure({ level: 'info', label: 'main' })
const isolated = logger.newInstance()
isolated.configure({ level: 'warn', label: 'isolated' })
isolated.info('dropped — below warn') // not emitted
isolated.warn('isolated warn') // {"label":"isolated",...}
logger.info('singleton unaffected') // {"label":"main",...}Subclass AbstractTransport and implement async write(entry). The entry argument is the parsed pino log object (plain JS object, same shape as the JSON stdout line).
const { AbstractTransport } = require('util-logger/transports/abstract')
const { setTimeout } = require('timers/promises')
class MemoryTransport extends AbstractTransport {
constructor (options) {
super(options) // options.level — own minimum level (optional)
this.entries = []
}
async write (entry) {
await setTimeout(0) // must be genuinely async
this.entries.push(entry)
}
}
const mem = new MemoryTransport({ level: 'warn' })
logger.configure({ level: 'info', transports: [mem] })
logger.info('stdout only — below mem.level warn')
logger.warn('stdout + mem')
logger.error({ code: 500 }, 'stdout + mem')
// after a tick:
// mem.entries.length === 2
// mem.entries[0].msg === 'stdout + mem'If write() throws, the error is caught by the transport stream and a fallback JSON line is written to stdout. The transport loop continues for subsequent entries; the caller never sees the error.
class FailingTransport extends AbstractTransport {
async write (entry) { throw new Error('simulated write failure') }
}
// fallback line emitted to stdout:
// {"level":50,"label":"util-logger:internal","msg":"Transport write failed","transport":"FailingTransport","error":"simulated write failure",...}Appends a transport after initial configure() without replacing other settings. Mutates in place, returns this (chainable).
logger.configure({ level: 'info', label: 'auth-service' })
logger.info('stdout only')
// add later — e.g. after loading config from file
logger.addTransport(new HyperswarmTransport({ topic: 'logs', app: 'auth', secretKey: 'x', level: 'warn' }))
logger.warn('stdout + hyperswarm')Returns a new independent Logger with the current config plus the given transport. Does not modify the original. Use when a specific operation (a request, a transaction, a job) needs an extra transport scoped to that operation only.
logger.configure({
level: 'info',
label: 'auth-service',
transports: [new HyperswarmTransport({ topic: 'my-app-logs', app: 'auth', secretKey: 'x', level: 'warn' })]
})
const auditLogger = logger.withTransport(
new HyperswarmTransport({ topic: 'audit-logs', app: 'auth', secretKey: 'x', level: 'info' })
)
auditLogger.info('withdrawal initiated') // stdout + my-app-logs (warn gate) + audit-logs
logger.info('unrelated request') // stdout + my-app-logs only — audit sink not attachedAppends a worker-thread target after initial configure() without replacing other settings. Returns this. Use the same way as addTransport() but for targets mode — when you want crash isolation between the transport and the main process.
logger.configure({ level: 'info', label: 'auth-service' })
logger.info('stdout only')
logger.addTarget({
target: require.resolve('util-logger/transports/hyperswarm-worker'),
options: { topic: 'my-app-logs', app: 'auth', secretKey: 'x', level: 'warn' },
level: 'warn'
})
logger.warn('stdout + hyperswarm worker')addTransport() throws if the logger is in targets mode. addTarget() throws if transports are configured. The two modes are mutually exclusive at all times — not just in configure().
destinations replaces the automatic stdout stream. Each entry is an object with level and destination (file descriptor or path).
When two or more destinations are provided, pino's dedupe: true routes each log entry to exactly one stream — the destination with the highest level that still accepts the entry. When transports are also present, destinations are wrapped in their own inner deduped multistream so transport levels cannot compete in the dedupe algorithm — destinations always receive the entries they should.
// Route debug+info → stdout, warn/error/fatal → stderr
logger.configure({
level: 'debug',
destinations: [
{ level: 'debug', destination: 1 }, // fd 1 = stdout
{ level: 'warn', destination: 2 } // fd 2 = stderr
]
})
logger.debug('→ stdout')
logger.info('→ stdout')
logger.warn('→ stderr') // NOT duplicated on stdout
logger.error('→ stderr')Silence stdout completely — pass destinations: []:
logger.configure({
destinations: [], // no stdout, no stderr
transports: [new HyperswarmTransport(opts)] // transport is the only sink
})
logger.info('only hyperswarm receives this')targets switches to pino's native transport.targets mode. Each target runs in a dedicated worker thread — transport crashes cannot kill the main process. Options must be plain serialisable values (no class instances).
transports (class instances) and targets cannot be mixed in the same configure() call.
logger.configure({
level: 'info',
label: 'auth-service',
// stdout is added automatically from destinations (or default stdout) in targets mode
targets: [
{
target: require.resolve('util-logger/transports/hyperswarm-worker'),
options: {
topic: 'my-app-logs',
app: 'auth-service',
secretKey: 'shared-secret',
level: 'warn'
}
}
]
})Peer dependencies required.
HyperswarmTransportis not loaded until you use it, buthyperswarmandb4amust be installed in your project:npm install hyperswarm b4a
Fans log entries out over a Hyperswarm peer-to-peer network. Requires topic, app, and secretKey. Supports both main-thread (class instance) and worker-thread (pino target) modes.
const { HyperswarmTransport } = require('util-logger/transports/hyperswarm')
// Main-thread mode — runs in the same event loop
const swarm = new HyperswarmTransport({
topic: 'my-app-logs', // required — hashed into a 32-byte discovery key
app: 'auth-service', // required — sent in the auth handshake
secretKey: 'shared-secret', // required — sent in the auth handshake
level: 'warn', // optional — own minimum level
maxRetries: 3, // optional — retries per write (default 3)
retryDelay: 200 // optional — base delay ms between retries (default 200)
})
logger.configure({ level: 'info', transports: [swarm] })
logger.info('stdout only') // below swarm level:warn
logger.warn('stdout + swarm') // both sinksWorker-thread mode — the file exports a pino transport factory, so it can be used directly as a target:
logger.configure({
targets: [
{
target: require.resolve('util-logger/transports/hyperswarm-worker'),
options: { topic: 'my-app-logs', app: 'auth-service', secretKey: 'x', level: 'warn' }
}
]
})If there is no active Hyperswarm connection, write calls are silently dropped. Send failures after connection are logged to stdout via the fallback path.
Fans log entries out to Slack via a grcSlack instance. Main-thread only — gRPC client objects are not serialisable across worker-thread boundaries.
grcSlack must expose:
.message(channel, text)→ Promise.logError(channel, err, msg)→ Promise
const { SlackTransport } = require('util-logger/transports/slack')
const slack = new SlackTransport(grcSlack, {
level: 'warn', // optional — own minimum level
channel: '#alerts' // optional — passed to grcSlack.message / logError
})
logger.configure({ transports: [slack] })
logger.warn('high memory') // grcSlack.message('#alerts', '[WARN] high memory')
logger.error(new Error('timeout'), 'db') // grcSlack.logError('#alerts', err, 'db')For error/fatal entries, SlackTransport always calls grcSlack.logError() — with a reconstructed Error object when an err field is present, or with the message string as the error arg when it is not. Extra fields are passed as additional variadic args. For all other levels, calls grcSlack.message() with a formatted string: [label] [LEVEL] msg | key="value" ...
const logger = require('util-logger') // singleton Logger instance
const { AbstractTransport } = require('util-logger/transports/abstract')
const { HyperswarmTransport } = require('util-logger/transports/hyperswarm')
const { SlackTransport } = require('util-logger/transports/slack')
// worker-thread target factory (pino targets mode):
// require.resolve('util-logger/transports/hyperswarm-worker')| File | What it shows |
|---|---|
examples/01-basic.js |
Basic log methods |
examples/02-configure.js |
configure(), label, invalid level |
examples/03-child-loggers.js |
child() and grandchild |
examples/04-new-instance.js |
newInstance() isolation |
examples/05-async-transport.js |
Custom MemoryTransport with level filter |
examples/06-transport-failure.js |
Failing transport — fallback + loop continues |
examples/07-hyperswarm-main-thread.js |
HyperswarmTransport in main-thread mode |
examples/08-hyperswarm-worker-thread.js |
HyperswarmTransport as pino targets worker |
examples/09-stdout-stderr-split.js |
destinations routing: debug/info → stdout, warn+ → stderr; extra transport at error+ |
examples/10-add-transport.js |
addTransport() after initial configure |
examples/11-disable-stdout.js |
destinations: [] — silence stdout; transport is the only sink |
examples/12-with-transport.js |
withTransport() — scoped logger for a specific operation |
examples/13-add-target.js |
addTarget() — append worker-thread target after initial configure |