A lightweight, pluggable, and testable logger for modern JavaScript and TypeScript apps — designed for real codebases, not just demos.
plainlog works in Node.js and the browser, supports context inheritance, buffering, transport safety, and is intentionally small enough that you can actually understand it.
📦 npm: https://www.npmjs.com/package/plainlog
Most popular loggers fall into one of two camps:
- Large APIs with many concepts
- Hard to reason about in tests
- Context propagation is awkward or missing
- Easy to misuse, hard to debug
- Optimized for throughput over ergonomics
- Minimal context support
- Not great for testing or small tools
- Often feels “too low-level” for application logic
plainlog is built around a simple idea:
Logging should be easy to reason about, easy to test, and never break your app.
That means:
- No global singletons
- No magic context
- No hidden async behavior
- No logging failures crashing your process
- No framework lock-in
plainlog is especially well-suited for:
- application code
- CLIs and scripts
- browser apps
- test environments
- distributed systems glue code
- Log levels:
debug,info,warn,error - Structured log entries with ISO timestamps
- Context inheritance (
withContext,child) - Fire-and-forget logging or awaitable logging
- Transport isolation (one bad transport won’t break others)
- Timeout-protected transports
- Centralized error handler with context
- Optional buffering with flush semantics
- Configurable buffer limits
- Test mode to capture logs in memory
- Deterministic async APIs for assertions
- No globals or hidden state
- Pluggable transports
- Optional lifecycle hooks (
flush,close) - Meta sanitization hook to prevent leaks
- Simple transport factory helper
- Works in Node.js and the browser
- Browser-safe core + transports
- Node-only transports exported separately
npm install plainlogimport { Logger } from "plainlog";
const logger = new Logger("info");
logger.info("App started");
logger.warn("Low disk space", { freeMB: 120 });
logger.error("Something broke", { code: "E_FAIL" });Logs are structured objects internally and only formatted by transports.
import { ConsoleTransport } from "plainlog/transports";
logger.use(new ConsoleTransport());import { JsonConsoleTransport } from "plainlog/transports";
logger.use(new JsonConsoleTransport());import { SilentConsoleTransport } from "plainlog/transports";
logger.use(new SilentConsoleTransport());import { FileTransport } from "plainlog/transports/node";
logger.use(new FileTransport("./app.log"));Other node transports:
ProcessStreamTransportIpcTransportSmartFileTransport
plainlog supports explicit context propagation.
const base = new Logger("info", { service: "api" });
const requestLogger = base.withContext({ reqId: "abc123" });
requestLogger.info("Handling request");logger.withContext({ a: 1, b: 2 });
logger.withContext({ b: 9 }, "replace");const child = logger.child({ userId: "u1" });By default, logging is fire-and-forget:
logger.info("Hello"); // no await neededIf you need determinism (tests, shutdown, error handling):
await logger.infoAsync("Hello");Available async variants:
debugAsyncinfoAsyncwarnAsyncerrorAsynclogAsync
Useful for startup, batch jobs, or controlled output.
logger.enableBufferMode();
logger.info("Buffered");
logger.info("Still buffered");
await logger.flush(); // writes all buffered logsBuffers are capped to prevent memory leaks.
Capture logs without a transport:
logger.enableTestMode();
await logger.warnAsync("Rate limited");
expect(logger.testLogs()).toEqual([
expect.objectContaining({ level: "warn" })
]);Test logs are isolated and capped.
Transport failures never throw.
Instead, you can register a handler:
logger.setErrorHandler((err, { transport, entry }) => {
// report to Sentry, metrics, etc.
});Timeouts are treated as errors too.
Prevent secrets from leaking into logs:
logger.setSanitizer((meta) => {
const { token, password, ...safe } = meta;
return safe;
});Runs for:
- normal logging
- raw log injection
- all transports
import type { Transport, LogEntry } from "plainlog";
class MyTransport implements Transport {
async log(entry: LogEntry) {
await sendSomewhere(entry);
}
async flush() {}
async close() {}
}import { createTransport } from "plainlog";
import { prettyFormatter } from "plainlog/formatter";
const transport = createTransport(console.log, prettyFormatter);
logger.use(transport);For quick setup with sane defaults:
import { createLogger } from "plainlog";
const logger = createLogger({
level: "info",
});Supports:
- env-based
LOG_LEVEL - default console transport
- custom transports
- buffer limits
debug | info | warn | errordebugAsync | infoAsync | warnAsync | errorAsyncwithContext,childenableBufferMode,flush,closeenableTestMode,testLogssetLevel,setErrorHandler,setSanitizerlog,logAsync
log(entry)flush?()close?()
MIT