# Node.js Structured Logging API - RFC **Author:** @mertcanaltin **Status:** DRAFT **Date:** 2025-01-22 **Issue:** [#49296](https://github.com/nodejs/node/issues/49296) --- ## Summary Add a structured logging API to Node.js core (`node:log`) inspired by Go's `slog`, providing a standard, performant logging interface with handler-based output formatting. --- ## Implementation Status **Current Phase:** Planning / RFC Review **Blockers:** - SonicBoom port ([#58897](https://github.com/nodejs/node/pull/58897)) in progress by @jasnell - Prototype implementation pending - Benchmark suite not yet created **Next Steps:** 1. Wait for SonicBoom port to land in core 2. Build working prototype using SonicBoom 3. Implement benchmark suite 4. Validate performance against Pino 5. Update RFC with actual performance data 6. Open implementation PR --- ## Motivation **Current Problem:** - No standard logging in Node.js stdlib - Ecosystem fragmentation: console.log, heavy deps (Winston 200+ packages), custom solutions - Libraries either don't log or bring heavy dependencies **Solution:** - Building-block API with handler pattern (Go slog style) - Structured logging with mandatory messages - High performance via async buffering (SonicBoom) - Flexible output: JSON (servers), Text (CLI), custom (OTEL) --- ## API Design ### Basic Usage ```javascript const { createLogger, JSONHandler, TextHandler } = require('node:log'); // Server logging (JSON output) const logger = createLogger({ handler: new JSONHandler({ level: 'info' }) }); logger.info({ msg: 'user login', userId: 123, ip: '192.168.1.1' }); // Output: {"level":"info","msg":"user login","userId":123,"ip":"192.168.1.1","time":1737504000000} // CLI logging (human-readable) const cliLogger = createLogger({ handler: new TextHandler({ colorize: true }) }); cliLogger.warn({ msg: 'disk space low', available: '2GB' }); // Output: [WARN] disk space low available=2GB ``` ### Child Loggers ```javascript const logger = createLogger({ level: 'info' }); // Add context to all child logs const reqLogger = logger.child({ requestId: 'abc-123' }); reqLogger.info({ msg: 'processing' }); // Output: {...,"msg":"processing","requestId":"abc-123"} // Nested children inherit parent context const dbLogger = reqLogger.child({ component: 'db' }); dbLogger.debug({ msg: 'query', duration: 45 }); // Output: {...,"requestId":"abc-123","component":"db","duration":45} ``` **Child logger behavior:** - Shallow merge (child fields override parent) - Immutable (returns new instance) - Inherits handler from parent --- ## Core API ### `createLogger(options)` Creates a logger instance. **Options:** - `handler` (Handler) - Output handler (default: JSONHandler) - `level` (string) - Minimum log level (default: 'info') **Returns:** Logger ### Logger Methods All methods accept a single object with required `msg` field: ```javascript logger.trace({ msg: string, ...fields }) logger.debug({ msg: string, ...fields }) logger.info({ msg: string, ...fields }) logger.warn({ msg: string, ...fields }) logger.error({ msg: string, ...fields }) logger.fatal({ msg: string, ...fields }) // Does NOT exit process ``` **Design Decision:** Single object API (per @mcollina feedback): - Unambiguous JSON serialization - Enforces mandatory messages - Avoids Pino's argument order confusion - Easy OTEL transformation **Throws:** `ERR_INVALID_ARG_TYPE` if `msg` missing or not string ### `logger.child(bindings[, options])` Creates child logger with inherited context. **Parameters:** - `bindings` (Object) - Context fields for all logs - `options` (Object) - Optional overrides - `level` (string) - Override parent level **Returns:** Logger (new instance) ### `logger.enabled(level)` Check if level would be logged (avoid expensive operations). ```javascript if (logger.enabled('debug')) { logger.debug({ msg: 'data', expensive: JSON.stringify(largeObj) }); } ``` **Returns:** boolean --- ## Handlers Handlers define output format and destination. ### Handler Interface ```javascript class CustomHandler { handle(record) { // record.level, record.msg, record.time, record.bindings, record.fields } enabled(level) { return LEVELS[level] >= LEVELS[this.level]; } } ``` ### Built-in Handlers #### JSONHandler (Server Logs) ```javascript new JSONHandler({ level: 'info', // Default: 'info' stream: process.stdout, // Default: stdout fields: { // Additional fields hostname: os.hostname(), pid: process.pid } }) ``` **Output:** `{"level":"info","msg":"...","time":123,...}` #### TextHandler (CLI Logs) ```javascript new TextHandler({ level: 'debug', stream: process.stderr, colorize: true, // Auto-detect TTY timestamp: true // Default: true }) ``` **Output:** `2025-01-22 10:30:45 [INFO] message key=value` #### Custom Handlers (e.g., OTEL) ```javascript class OTELHandler { constructor({ exporter, level }) { this.exporter = exporter; this.level = level || 'info'; } handle(record) { this.exporter.export({ timestamp: record.time, severityText: record.level.toUpperCase(), body: record.msg, attributes: { ...record.bindings, ...record.fields } }); } enabled(level) { return LEVELS[level] >= LEVELS[this.level]; } } const logger = createLogger({ handler: new OTELHandler({ exporter: new OTLPLogExporter() }) }); ``` --- ## Log Levels Following **log4j interface** with **RFC5424 numerical ordering**: | Level | Value | Description | |--------|-------|----------------------------------| | trace | 10 | Very verbose debugging | | debug | 20 | Debugging information | | info | 30 | Informational (default) | | warn | 40 | Warning conditions | | error | 50 | Error conditions | | fatal | 60 | Critical errors (**no exit**) | **Note:** `fatal()` logs only - does NOT call `process.exit()` (per @jsumners feedback) --- ## Performance ### Async Buffering Based on SonicBoom (PR [#58897](https://github.com/nodejs/node/pull/58897)): - 4KB buffer, 10ms flush interval - Batched writes minimize syscalls - Non-blocking I/O ### Object Allocation ```javascript // Skip allocation for disabled levels if (!this.enabled(level)) return; // Merge only when needed const record = { level, msg: obj.msg, time: Date.now(), ...this.bindings, ...obj }; ``` ### Lazy Evaluation Use `enabled()` to skip expensive operations: ```javascript if (logger.enabled('debug')) { logger.debug({ msg: 'details', data: computeExpensiveData() }); } ``` --- ## Performance Strategy ### Approach Performance will be validated through comprehensive benchmarking against Pino. The implementation will leverage the same high-performance infrastructure (SonicBoom) that powers Pino, providing a solid foundation for competitive performance. ### Benchmark Plan **Benchmark Suite** (`benchmark/log/`) - `basic-json.js` - JSON handler throughput - `child-loggers.js` - Context inheritance overhead - `level-filtering.js` - Disabled level check cost - `vs-pino.js` - Direct comparison with Pino **Comparison Scenarios** - Simple logs (msg only) - Structured logs (msg + 5 fields) - Child logger with 3 levels of nesting - High throughput (10k+ ops/sec) - Disabled level overhead **Target:** Performance comparable to Pino (specific thresholds will be determined after prototype benchmarking) ### Performance Foundation The implementation will leverage the same infrastructure as Pino: - **SonicBoom** for async I/O (being ported to core in [#58897](https://github.com/nodejs/node/pull/58897)) - **Fast JSON serialization** techniques - **Efficient object handling** - minimize allocations - **Early exit** for disabled levels This shared foundation provides a solid basis for competitive performance, which will be validated through benchmarking. ### Why Performance Should Be Comparable Both `node:log` and Pino will share: - Same underlying SonicBoom infrastructure for I/O - Similar object pooling and reuse strategies - Same async write patterns and buffering The main difference will be API design (single object vs Pino's optional merge object), which should have minimal performance impact. --- ## Implementation Phases ### Phase 1: Core API (v24.x) **Status:** Blocked on SonicBoom port ([#58897](https://github.com/nodejs/node/pull/58897)) **Deliverables:** - Logger class with level methods - JSONHandler, TextHandler - Child loggers with context inheritance - Level filtering, `enabled()` checks - Message validation - **Benchmark suite implementation** - **Performance validation vs Pino** - Tests & documentation **Merge Criteria:** - All unit tests pass - Benchmark suite runs successfully - Performance meets acceptance criteria (to be defined) - Documentation complete - API review approved by core team **Dependency:** SonicBoom port ([#58897](https://github.com/nodejs/node/pull/58897)) - in progress by @jasnell ### Phase 2: Advanced Features (v25.x) **Deliverables:** - Transport system (off-thread processing) - ThreadStream integration - Log redaction for sensitive data - Performance optimizations based on Phase 1 learnings **Dependency:** ThreadStream port to core (volunteer needed per @jasnell) ### Phase 3: Ecosystem Integration (v26.x) **Deliverables:** - diagnostics_channel integration - OTEL handler examples and documentation - Cloud provider transport examples - Performance profiling tools --- ## Open Questions 1. **Module name:** `node:log` vs `node:logger` vs `node:logging`? *Current preference: `node:log` (brevity)* 2. **ThreadStream port:** Volunteer needed for porting ThreadStream to core *Impact: Blocks Phase 2 (off-thread processing)* *Reference: Mentioned by @jasnell in [#49296](https://github.com/nodejs/node/issues/49296)* 3. **OTEL handler:** Built-in or userland example? *Current direction: Userland example (Phase 3)* 4. **Benchmark acceptance criteria:** What is the minimum acceptable performance vs Pino? *To be determined after initial prototype benchmarking* *Note: Will be based on real-world testing, not speculation* 5. **API compatibility:** Should we support a Pino-compatible mode initially? *Reference: @jasnell suggested "drop-in replacement for pino" as initial goal* --- ## Consensus Points Based on discussion in [#49296](https://github.com/nodejs/node/issues/49296): - **API signature:** Single object with required `msg` (@mcollina approved) - **Handler pattern:** JSONHandler, TextHandler, custom (@jsumners, @wesleytodd) - **Child loggers:** Context inheritance (@mcollina requested) - **Levels:** RFC5424 ordering, log4j interface (@mcollina, @jsumners) - **fatal() behavior:** Log only, no exit (@jsumners) - **OTEL:** Via handler, not built-in (@mcollina) - **Building block:** Minimal core, flexible handlers (@wesleytodd) - **Performance:** Must be on-par with Pino (@mcollina) - **Initial approach:** Start as Pino drop-in replacement, then refine (@jasnell) --- ## References ### Related PRs & Issues - **Main Issue:** [#49296](https://github.com/nodejs/node/issues/49296) - **SonicBoom port:** [#58897](https://github.com/nodejs/node/pull/58897) (@jasnell - in progress) ### External References - **Go slog:** https://go.dev/blog/slog - **RFC5424:** https://datatracker.ietf.org/doc/html/rfc5424 - **log4j Levels:** https://logging.apache.org/log4j/2.x/manual/ - **Pino:** https://github.com/pinojs/pino - **ThreadStream:** https://github.com/pinojs/thread-stream (core port needed , I want to help this topic) --- ## License MIT ---