npm.io
0.6.0 • Published 4d ago

@ctrlback/logtau

Licence
MIT
Version
0.6.0
Deps
0
Size
72 kB
Vulns
0
Weekly
0

@ctrlback/logtau

A small, ESM-only, structured logger for modern Node.js. Output is NDJSON, the public API is small, and the disabled-log path costs nothing.

Requirements

  • Node.js >= 22.6
  • Pure ESM consumer (the package is "type": "module" and has no CJS export)

Install

pnpm add @ctrlback/logtau

Quick start

import { createLogger, stdoutSink } from '@ctrlback/logtau'

const log = createLogger({ level: 'info', sink: stdoutSink() })

log.info('server started', { port: 3000 })
log.warn('rate limit nearing', { remaining: 12 })
log.error('request failed', { err: new Error('timeout') })

stdoutSink() writes NDJSON to stdout with sensible buffering defaults; pass { sync: true } for a write per line instead.

Add request-scoped fields

with() (alias child()) returns a logger that carries the given fields on every record. Bindings are serialized once at derivation, not on every call.

const reqLog = log.with({ reqId: 'a1b2', route: '/users' })
reqLog.info('handled')                  // includes reqId & route
reqLog.error('failed', { status: 500 }) // per-call fields win on collision
Configure once, look up by category

For applications that want one place to set levels and sinks across many modules, the registry resolves loggers by category using longest-prefix match. Before configure() runs, every logger from getLogger() is an inert no-op, so log calls from a library that imports logtau cost a host nothing until the host opts in.

import { configure, getLogger, stdoutSink } from '@ctrlback/logtau'

configure({
  sinks: { out: stdoutSink() },
  loggers: [
    { category: [], level: 'info', sinks: ['out'] },
    { category: ['db'], level: 'debug' },
  ],
})

const log = getLogger(['db', 'pool'])
log.debug('connection acquired', { id: 42 })

Output format

Every record is one JSON object per line (NDJSON). The reserved keys are always emitted first:

{"level":"info","time":1717000000000,"msg":"connection acquired","id":42}

time is epoch milliseconds from the configured Clock; the default uses Date.now. The reserved keys (level, time, msg) collide with user fields of the same name, so avoid those in fields and bindings.

Levels

Six levels, ascending:

Level Behaviour
debug Standard write.
info Standard write.
warn Standard write.
error Standard write.
critical Writes, then sink.flushSync() so the line escapes.
fatal Writes, then sink.flushSync() so the line escapes.

Any level below the configured threshold is replaced by a shared no-op at logger construction, so disabled calls carry no per-log work.

Sinks

A sink consumes already-formatted lines. The bundled sinks:

  • stdoutSink(options?): writes to fd 1. Defaults to buffered (16 KiB threshold, 5 s lazy timer, flush-on-exit). Pass { sync: true } for per-line synchronous writes.
  • createFdSink(fd, options?): the same sink against an arbitrary file descriptor.
  • multiSink([a, b, ...]): fan a single write out to several sinks. A single-sink array is returned unchanged. write() aborts on the first thrown error; flush(), flushSync() and close() give every sink a chance to run and aggregate errors (AggregateError when more than one fails).

You can implement the Sink interface directly to send logs anywhere (a file, a transport, an in-memory buffer for tests).

Buffering and shutdown

The buffered stdout sink flushes synchronously on the process exit event (opt out with flushOnExit: false). The listener is additive and is removed by close(). Signals (SIGINT / SIGTERM) are deliberately never touched so the sink cannot interfere with the host's signal handling. For signal-driven shutdown, flush from your own handler:

process.on('SIGTERM', () => {
  sink.flushSync()
  process.exit(0)
})

Or use { sync: true } and accept the throughput trade-off.

The internal timer is unref'd, so a buffered sink never keeps the event loop alive on its own.

Sink.close() flushes, releases timers and removes the 'exit' listener. Subsequent write() / flush() / flushSync() calls throw "logtau sink: already closed" instead of silently buffering into a sink that will never flush. close() itself is idempotent.

Redaction

A redaction policy declares which paths to censor (replace value with "[REDACTED]") or remove (drop the key). Policies are compiled once and applied to both fields and bindings.

configure({
  sinks: { out: stdoutSink() },
  loggers: [{ category: [], level: 'info', sinks: ['out'] }],
  redact: {
    censor: ['user.token', 'headers.authorization', '*.password'],
    remove: ['user.ssn', 'payload[*].cardNumber'],
  },
})

Supported path syntax:

Pattern Matches
a.b.c literal dot-separated segments
a.*.c one wildcard object key
a[*].c every array element
*.x wildcard at the root

On collision between the two lists, remove wins (stricter outcome). Inside arrays remove degenerates to censor so element indices stay stable. When no policy is configured the formatter stays on JSON.stringify, so the cost of having the feature available is zero.

You can also build a policy outside the registry and hand it to createLogger directly:

import { compilePolicy, createLogger, stdoutSink } from '@ctrlback/logtau'

const redact = compilePolicy({ censor: ['user.password'] })
const log = createLogger({ level: 'info', sink: stdoutSink(), redact })

Error fields

A field named err is shaped through serializeError by default, producing { name, message, stack?, cause? } and breaking circular cause chains with "[Circular]".

Custom serializers and toJSON

Pass serializers to createNdjsonFormatter to transform a value before serialization based on its top-level field name:

import { createNdjsonFormatter, serializeError } from '@ctrlback/logtau'

const formatter = createNdjsonFormatter({
  serializers: {
    err: (v) => (v instanceof Error ? serializeError(v) : v),
    req: (v) => (v as { id?: string }).id,
  },
})

Values implementing toJSON() are honored in both the fast path (via JSON.stringify) and the redaction walker. bigint, Map, Set, circular references, and Error are handled when the native path can't serialize them.

Keywords