nexlogger
v0.1.0
Published
Zero-dep Node.js logger — library-safe silent mode, auto env-aware output, ALS correlation, OTel injection
Maintainers
Readme
nexlogger
Zero-dependency, structured Node.js logger with library-safe silent mode, built-in redaction, async context correlation, and OpenTelemetry injection.
Requires Node.js >= 18.
npm install nexloggerQuick Start
App Mode
import { createLogger } from 'nexlogger'
const log = createLogger({ name: 'my-app' })
log.info('server started')
log.info({ userId: 42 }, 'user login')
log.warn('disk usage high', { percent: 92 })
log.error(new Error('connection lost'))Output auto-detects environment: pretty with colors in development, JSON in production.
Library Mode
import { getLogger } from 'nexlogger'
const log = getLogger('my-lib')
export function doWork() {
log.info('processing') // silent until host app calls configure()
}Libraries ship with nexlogger but produce zero output until the consuming application opts in. No forced logging, no dependency conflicts.
Activating Library Loggers
import { configure } from 'nexlogger'
configure({
loggers: [
{ name: 'my-lib', level: 'info' },
{ name: 'my-lib:*' }, // glob: activates my-lib:db, my-lib:cache, etc.
],
})Log Levels
Six levels, ordered by severity:
| Level | Weight | Use |
|-------|--------|-----|
| trace | 10 | Fine-grained debugging |
| debug | 20 | Development diagnostics |
| info | 30 | Normal operations (default) |
| warn | 40 | Recoverable issues |
| error | 50 | Failures |
| fatal | 60 | Unrecoverable, process should exit |
Set via option or LOG_LEVEL environment variable:
const log = createLogger({ level: 'debug' })
log.setLevel('warn') // change at runtimeFlexible Call Signatures
Every log method accepts multiple argument styles:
log.info('simple message')
log.info('user %s logged in from %s', name, ip) // printf-style
log.info({ userId: 42 }, 'user login') // pino-style: object first
log.info('user login', { userId: 42 }) // object second
log.info(new Error('boom')) // error object (uses err.message)
log.info(new Error('boom'), 'custom message') // error + override messageErrors are serialized into meta.err with all own properties preserved (message, stack, name, code, cause, syscall, etc.).
Child Loggers
Create child loggers with bound metadata that merges into every log entry:
const log = createLogger({ name: 'app' })
const dbLog = log.child({ module: 'database' })
const queryLog = dbLog.child({ pool: 'primary' })
queryLog.info('query executed', { duration: 12 })
// output includes: module: 'database', pool: 'primary', duration: 12Transports
Built-in Transports
JSON (default in production) - one JSON object per line to stdout:
import { jsonTransport } from 'nexlogger'
const log = createLogger({ transports: [jsonTransport()] })Pretty (default in development) - color-coded, human-readable to stdout:
import { prettyTransport } from 'nexlogger'
const log = createLogger({ transports: [prettyTransport({ maxMetaLength: 300 })] })Falls back to JSON automatically when stdout is not a TTY (e.g., piped to a file).
File - synchronous append to a file in JSONL format:
import { fileTransport } from 'nexlogger'
const log = createLogger({ transports: [fileTransport('logs/app.log')], file: false })Auto-creates directories. Default path: logs/app.log.
Async File (default file transport) - buffered writes using streams for higher throughput:
import { asyncFileTransport } from 'nexlogger'
const transport = asyncFileTransport({
path: 'logs/app.log',
flushIntervalMs: 100, // flush buffer every 100ms (default)
bufferSize: 1000, // flush when buffer hits 1000 entries (default)
})
const log = createLogger({ transports: [transport], file: false })
// Graceful shutdown:
await transport.close()createLogger uses asyncFileTransport() by default (path: logs/app.log) unless file: false.
Rotating File - automatic log rotation by size and/or time:
import { rotatingFileTransport } from 'nexlogger'
const log = createLogger({
transports: [
rotatingFileTransport({
path: 'logs/app.log',
maxSize: 10 * 1024 * 1024, // rotate at 10MB (default)
maxFiles: 5, // keep 5 rotated files (default)
rotateEvery: 'daily', // also rotate daily (optional: 'daily' | 'hourly')
}),
],
file: false,
})Old files are named app.<timestamp>.log and cleaned up automatically.
Per-Transport Level Filtering
Send different levels to different destinations:
const log = createLogger({
transports: [
prettyTransport(), // all levels
{ transport: fileTransport('errors.log'), level: 'error' }, // errors only
],
file: false,
})Custom Transports
A transport is any function (entry: LogEntry) => void | Promise<void>:
import type { LogEntry } from 'nexlogger'
const myTransport = (entry: LogEntry) => {
fetch('https://logs.example.com/ingest', {
method: 'POST',
body: JSON.stringify(entry),
})
}
const log = createLogger({ transports: [myTransport] })Async transports are fire-and-forget. Use logger.flush() to await all pending writes.
Transport Error Handling
By default, transport errors are silently swallowed to prevent logging from crashing your app. Use onError to observe failures:
const log = createLogger({
transports: [unreliableTransport],
onError: (err, entry) => {
console.error('Transport failed:', err, 'for entry:', entry.msg)
},
})Works for both synchronous throws and async rejections.
Redaction
Scrub sensitive data before it reaches any transport.
Path-based - redact specific nested keys:
const log = createLogger({
redact: ['user.password', 'headers.authorization'],
})
log.info({ user: { name: 'Alice', password: 's3cret' } }, 'login')
// password → [REDACTED]Wildcard arrays - redact across array items:
const log = createLogger({
redact: ['users[*].token'],
})Regex patterns - match and replace values by pattern:
const log = createLogger({
redact: [
{ pattern: /\b\d{16}\b/g, replace: '[CARD]' },
{ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, replace: '[EMAIL]' },
],
})Fast mode - auto-detect common PII keys (password, token, secret, apikey, api_key, authorization, ssn, creditcard, credit_card):
const log = createLogger({ redact: 'fast' })Redaction is non-mutating: input objects are never modified.
Request Correlation (AsyncLocalStorage)
Automatically attach context (like request IDs) to every log entry across async boundaries:
import { withContext, createLogger } from 'nexlogger'
const log = createLogger()
app.use((req, res, next) => {
withContext({ requestId: req.id, userId: req.user?.id }, () => next())
})
// Anywhere downstream, even in deeply nested async calls:
log.info('processing payment')
// output: { ..., requestId: 'abc-123', userId: 42 }Contexts nest. Inner calls merge with outer context:
withContext({ requestId: 'abc' }, () => {
withContext({ step: 'validate' }, () => {
log.info('check') // has both requestId and step
})
})Use enterContext when you can't wrap a callback (e.g., Fastify hooks):
import { enterContext } from 'nexlogger'
enterContext({ requestId: 'abc-123' })
// All subsequent logs in this async context carry requestIdExpress and Fastify Middleware
Built-in middleware auto-generates a request ID and propagates it via AsyncLocalStorage:
import { expressMiddleware } from 'nexlogger'
app.use(expressMiddleware())
// Reads X-Request-ID header or generates a UUID
// Sets X-Request-ID on the response
// All logs within the request carry the requestIdimport { fastifyPlugin } from 'nexlogger'
fastify.register(fastifyPlugin)HTTP Access Logs
Full morgan-style access logging with CLF, combined, and JSON formats:
import { createLogger, accessLogMiddleware } from 'nexlogger'
const log = createLogger({ name: 'access' })
// JSON format (default) - structured with method, url, status, duration:
app.use(accessLogMiddleware({ logger: log }))
// Common Log Format:
app.use(accessLogMiddleware({ logger: log, format: 'clf' }))
// Combined format (CLF + referrer + user-agent):
app.use(accessLogMiddleware({ logger: log, format: 'combined' }))
// Skip health checks:
app.use(accessLogMiddleware({
logger: log,
skip: (req) => req.url === '/health',
}))JSON format output includes method, url, status, duration (ms), contentLength, userAgent, and ip.
OpenTelemetry Trace Injection
Automatically injects traceId, spanId, and traceFlags from the active OpenTelemetry span into every log entry:
const log = createLogger({ otel: 'auto' }) // default: auto-detect| Value | Behavior |
|-------|----------|
| 'auto' (default) | Inject if @opentelemetry/api is installed, skip silently if not |
| true | Inject, throw if @opentelemetry/api is not installed |
| false | Never inject |
Install @opentelemetry/api as a peer dependency to enable:
npm install @opentelemetry/apiSource File and Line Tracking
Opt-in capture of the file and line number that produced each log entry:
const log = createLogger({ source: true })
log.info('hello')
// output: { ..., file: 'src/handlers/user.ts', line: 42 }Configure stack depth:
const log = createLogger({ source: { enabled: true, depth: 15 } })Internal framework frames are automatically skipped.
Log Sampling
Reduce log volume for high-frequency levels while keeping every warn/error/fatal:
const log = createLogger({
sample: {
trace: 0.01, // log 1% of trace entries
debug: 0.1, // log 10% of debug entries
info: 0.5, // log 50% of info entries
},
})When a requestId is present in context, sampling is deterministic per request: all logs for the same request either appear or don't, ensuring consistent traces. Falls back to random sampling without context.
warn, error, and fatal are never sampled.
debug Package Compatibility
Drop-in migration path from the debug package:
// Before:
// import debug from 'debug'
// const log = debug('myapp:server')
// After:
import { debug } from 'nexlogger'
const log = debug('myapp:server')
log.info('listening on port 3000')Respects the DEBUG environment variable:
DEBUG=myapp:* node server.jsProgrammatic control:
import { debug } from 'nexlogger'
debug.enable('myapp:*,-myapp:verbose')
debug.disable()Unlike debug, you get structured output, levels, redaction, and all other nexlogger features.
Async Flush and Graceful Shutdown
Await all pending async transport writes:
await log.flush()createLogger automatically registers shutdown hooks on SIGTERM and beforeExit that flush pending writes with a 500ms timeout, so logs are not lost on process exit.
Auto-Detection Behavior
createLogger() with no transport configuration auto-selects output:
| Condition | Transport |
|-----------|-----------|
| NODE_ENV=production | jsonTransport() to stdout |
| NODE_ENV != production | prettyTransport() to stdout |
| stdout is not a TTY | Pretty falls back to JSON |
| file option not false | asyncFileTransport() added (default: logs/app.log) |
Test Logger
Capture log entries in tests without any output:
import { createTestLogger } from 'nexlogger'
const { logger, entries, clear } = createTestLogger({ level: 'debug' })
logger.info('user created', { userId: 42 })
expect(entries).toHaveLength(1)
expect(entries[0].msg).toBe('user created')
expect(entries[0].meta.userId).toBe(42)
clear() // reset captured entriescreateTestLogger accepts all logger options except transports, file, and silent.
Level Check and Lazy Evaluation
Check if a level is enabled before performing expensive operations:
if (log.isLevelEnabled('debug')) {
log.debug('state', { snapshot: expensiveSerialize(state) })
}Or use lazy evaluation — pass a function that only runs when the level is enabled:
log.debug(() => 'computed: ' + expensiveCompute())
log.debug(() => ['message', { data: expensiveSerialize() }])The function is never called if the level is disabled, avoiding wasted computation.
Batch Transport
Buffer entries and send in batches — ideal for network transports (Datadog, Loki, CloudWatch):
import { batchTransport } from 'nexlogger'
const batch = batchTransport(
async (entries) => {
await fetch('https://logs.example.com/ingest', {
method: 'POST',
body: JSON.stringify(entries),
})
},
{
size: 100, // flush when buffer hits 100 entries (default)
intervalMs: 1000, // flush every 1s (default)
},
)
const log = createLogger({ transports: [batch.transport], file: false })
// Graceful shutdown:
await batch.flush() // flush pending
await batch.close() // flush + stop timerTyped Metadata
TypeScript generics enforce metadata shape at compile time:
import { createLogger } from 'nexlogger'
interface AppMeta {
userId: number
requestId: string
}
const log = createLogger<AppMeta>({ name: 'app' })
log.info('login', { userId: 42, requestId: 'abc' }) // ✓ compiles
log.info('login', { userId: 'wrong' }) // ✗ type error
const dbLog = log.child({ module: 'database' })
// dbLog has type LoggerInstance<AppMeta & { module: string }>Default generic is Record<string, unknown> — fully backward compatible.
Worker Thread Transport
Forward logs from worker threads to the primary thread's transports via MessagePort:
// primary.ts
import { Worker } from 'worker_threads'
import { receiveWorkerLogs, jsonTransport } from 'nexlogger'
const worker = new Worker('./worker.js')
const cleanup = receiveWorkerLogs(worker, [jsonTransport()])
// worker.ts
import { parentPort } from 'worker_threads'
import { createWorkerLogger } from 'nexlogger'
const log = createWorkerLogger(parentPort!)
log.info('processing in worker')workerTransport(port) is also available if you need to compose with other transports.
OTel Log Records
Emit logs as native OpenTelemetry LogRecords via @opentelemetry/api-logs:
import { otelLogTransport, createLogger } from 'nexlogger'
const log = createLogger({
transports: [otelLogTransport()],
file: false,
})
log.info('request handled', { duration: 42 })
// Emitted as OTel LogRecord with severity, timestamp, attributesRequires @opentelemetry/api-logs as a peer dependency:
npm install @opentelemetry/api-logsLog levels map to OTel severity numbers (TRACE=1, DEBUG=5, INFO=9, WARN=13, ERROR=17, FATAL=21). Source file and line are mapped to code.filepath and code.lineno attributes.
Per-Transport Redaction
Apply different redaction rules to different transports:
const log = createLogger({
transports: [
prettyTransport(), // no redaction — full data in dev console
{ transport: fileTransport('app.log'), redact: ['password'] }, // redact passwords in file
{ transport: networkTransport, redact: 'fast' }, // auto-detect PII for network
],
file: false,
})Per-transport redaction runs in addition to logger-level redaction (if configured). Supports the same rules: path-based, wildcard, regex, and 'fast' mode.
CLI Log Viewer
Pretty-print and filter JSONL log files from the terminal:
# Basic usage
npx nexlogger tail logs/app.log
# Filter by level
npx nexlogger tail logs/app.log --level error
# Grep messages
npx nexlogger tail logs/app.log --grep "payment"
# Follow mode (watch for new lines)
npx nexlogger tail logs/app.log -f
# Output as JSON (for piping)
npx nexlogger tail logs/app.log --level warn --jsonFull Options Reference
import { createLogger } from 'nexlogger'
const log = createLogger({
// Minimum level to log. Default: 'info' (or LOG_LEVEL env var)
level: 'debug',
// Logger name, included in output
name: 'my-app',
// Output destinations
transports: [jsonTransport(), fileTransport('app.log')],
// PII redaction rules
redact: ['user.password'] | [{ pattern: /regex/, replace: '[X]' }] | 'fast',
// Suppress all output
silent: false,
// OpenTelemetry trace injection
otel: 'auto', // 'auto' | true | false
// Source file + line capture
source: false, // true | false | { enabled: true, depth: 10 }
// Probabilistic sampling per level
sample: { trace: 0.01, debug: 0.1 },
// File transport control
file: true, // true | false | 'path/to/file.log'
// Transport error handler
onError: (err, entry) => { /* handle transport failure */ },
})Log Entry Structure
Every transport receives a LogEntry object:
{
level: 'info', // log level
msg: 'user login', // message string
time: '2024-01-15T10:30:00.000Z', // ISO 8601 timestamp
pid: 12345, // process ID
hostname: 'server-1', // system hostname
name: 'my-app', // logger name (if set)
meta: { userId: 42 }, // merged metadata (bindings + args + context)
traceId: 'abc...', // OpenTelemetry trace ID (if active)
spanId: 'def...', // OpenTelemetry span ID (if active)
traceFlags: 1, // OpenTelemetry trace flags (if active)
file: 'src/auth.ts', // source file (if source enabled)
line: 42, // source line (if source enabled)
}JSON and file transports flatten meta into the top level. Pretty transport shows meta inline after the message.
Dual CJS + ESM
Works in both module systems with full TypeScript types:
// ESM
import { createLogger } from 'nexlogger'
// CJS
const { createLogger } = require('nexlogger')All types are exported:
import type {
LogLevel, LogEntry, Transport, TransportWithLevel,
LoggerOptions, LoggerInstance,
ConfigureOptions, ConfigureLoggerEntry,
RedactRule, RedactPattern, SampleConfig, SourceConfig,
AsyncFileOptions, RotatingFileOptions,
AccessLogFormat, AccessLogOptions,
TestLoggerResult, BatchTransport, BatchTransportOptions,
} from 'nexlogger'Feature Summary
| Feature | Details |
|---------|---------|
| Zero dependencies | Node.js built-ins only. Optional @opentelemetry/api peer dep. |
| Dual CJS + ESM | Full support with proper .d.ts, .d.mts, .d.cts |
| Library-safe silent mode | getLogger() is a noop until configure() activates it |
| Auto env-aware output | Pretty in dev, JSON in prod, JSON fallback when not TTY |
| 6 log levels | trace, debug, info, warn, error, fatal |
| Flexible call signatures | String, printf, error, object-first (pino), object-second |
| Child loggers | .child({ key: value }) with inherited bindings |
| AsyncLocalStorage context | withContext() propagates across async boundaries |
| Semantic redaction | Path-based, wildcard arrays, regex patterns, fast auto-detect |
| OpenTelemetry injection | Auto traceId/spanId/traceFlags from active span |
| Source file + line | Opt-in stack capture with configurable depth |
| Log sampling | Per-level rates, deterministic per requestId |
| JSON transport | Circular reference + BigInt safe (all transports) |
| Pretty transport | Color-coded, TTY-aware, error stack display |
| File transport (sync) | Synchronous append, auto directory creation |
| Async file transport | Buffered stream writes, configurable flush interval (default in createLogger) |
| Rotating file transport | Size-based + time-based rotation, maxFiles cleanup |
| Per-transport level filter | Route error-only to a file, all levels to stdout |
| Custom transports | Any (entry) => void \| Promise<void> function |
| Transport error handling | onError callback for sync throws + async rejections |
| Async flush / drain | flush() awaits all pending async transports |
| Graceful shutdown | Auto SIGTERM/beforeExit hooks with 500ms timeout |
| Express middleware | Auto requestId generation + ALS propagation |
| Fastify plugin | Auto requestId generation + ALS propagation |
| HTTP access logs | CLF, combined, JSON formats with skip filter |
| debug package compat | DEBUG=app:* env var, debug.enable() / debug.disable() |
| Runtime level change | logger.setLevel('warn') |
| Level check | isLevelEnabled() avoids expensive computation |
| Lazy evaluation | log.debug(() => expensive()) — fn skipped when level disabled |
| Test logger | createTestLogger() captures entries for assertions |
| Batch transport | Buffer + flush for network destinations |
| Typed metadata | createLogger<TMeta>() with compile-time type checking |
| Worker thread transport | MessagePort-based log forwarding from workers |
| OTel LogRecord emission | Native OTel log records via @opentelemetry/api-logs |
| Per-transport redaction | Different redaction rules per transport destination |
| CLI log viewer | npx nexlogger tail with level/grep/follow filtering |
| TypeScript strict | Full type safety with generics and function overloads |
License
MIT
