Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

util-logger

Singleton-first structured JSON logger built on pino.

  • Singleton by defaultrequire('util-logger') returns the same instance everywhere
  • Configurable stdout — stdout on by default; override with destinations, silence with destinations: []
  • Custom async transports — subclass AbstractTransport to fan out to any destination (main thread)
  • Worker-thread targets — use targets for pino's native worker-thread transport mode
  • Native pino childrenlogger.child(bindings) returns a real pino child logger
  • Standard.js — no semicolons, single quotes, 2-space indent

Quick start

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 natively

configure(options)

Rebuilds 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 resets destinations, transports, and targets to their defaults unless explicitly provided. If you omit destinations, stdout is restored to default even if a previous call had changed it. Use addTransport() to append a transport while preserving the current destination setup. Transports dropped by a configure() call have close() called on them automatically.

Stdout routing reference

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 lost

If you need to add a transport without disturbing the current destination setup, use addTransport() instead of configure().

Child loggers

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.

New instance

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",...}

Custom transport

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",...}

addTransport / withTransport / addTarget

addTransport

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')

withTransport

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 attached

addTarget

Appends 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

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')

Worker-thread targets

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'
      }
    }
  ]
})

HyperswarmTransport

Peer dependencies required. HyperswarmTransport is not loaded until you use it, but hyperswarm and b4a must 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 sinks

Worker-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.

SlackTransport

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" ...

Exports

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')

Examples

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages