Architecture
lograil is organized in layers so each concern stays isolated and testable. A log call flows through them in a predictable order.
Layers
log.info(message, ...args)
│
▼
┌───────────────┐
│ Logger │ builds a structured LogEntry (timestamp, scope,
│ (facade) │ context, metadata, error, level)
└───────────────┘
│
▼
┌───────────────┐
│ Pipeline │ 1. Filters — drop entries (e.g. by level/scope)
│ │ 2. Processors — transform/enrich/redact/serialize
│ │ 3. Formatter — serialize to string/JSON
└───────────────┘
│
▼
┌───────────────┐
│ Plugins │ intercept each entry asynchronously (may drop/modify),
│ │ and can add transports / reshape the pipeline at runtime
└───────────────┘
│
▼
┌───────────────┐
│ Transports │ final sinks: console, rotating file, IPC, OTLP, custom
└───────────────┘
▲
│
┌───────────────┐
│ Runtime │ isolates env differences (clock, pid, fs, defaults)
│ Adapter │ — auto-detected, or supplied explicitly
└───────────────┘
Context — persistent structured fields attached to every entry (plus an
optional ambient async context).- Logger sends local calls and externally received entries through the same
LogEntryprocessing flow. The first argument may be astring, anError, or any value (objects are kept structured). The entry carriescontext(persistent, inherited request-scoped data) andmetadata(per-entry, usually injected by processors — see Context & Metadata for the distinction). - Pipeline runs
Filters (returningfalsedrops the entry), thenProcessors (in order, for enrichment, redaction, serialization, …), then aFormatterthat produces the on-wire representation. Built-in processors such ascreateRedactProcessorandcreateSerializeProcessorplug in here. - Plugins run after the pipeline via an async
intercepthook per entry. They can drop an entry (returnnull) or rewrite it, and they receive aPluginContextto reconfigure the logger at runtime. By default, redaction also runs after plugins so a plugin cannot reintroduce sensitive fields just before a write. - Transports are the sinks (
console,rotating file,IPC,OTLP, or your own). A transport'swritemay be async; async writes start sequentially in log order per transport, while independent transports do not block one another.flush()/destroy()wait for the queues. Each transport may also declare its ownlevelto filter independently. - Runtime Adapter abstracts the environment (Web / Node / Electron), so the same logger code runs everywhere.
- Context is merged into every entry's
contextfield; scoped loggers get an isolated child context. An ambient (async) context — request-scoped fields kept viaAsyncLocalStorageand auto-inherited acrossawait— is layered on top (browser: no-op).
Asynchrony & lifecycle
Logging is synchronous from the caller's perspective, but writes are drained through an internal queue so async transports and plugin interception never block your code:
logger.info('done'); // returns immediately
await logger.flush(); // wait until the queue (incl. async writes) is empty
await logger.destroy(); // flush, tear down plugins, close transportsBecause plugin onEntry hooks are awaited and chained, and async transport writes are queued, calling flush() (or destroy()) before process exit guarantees no buffered logs are lost. The Logger wires flush / crash behaviour through the runtime's lifecycle hooks, so autoFlushOnExit (or attachExitHandlers) flushes on host exit — Node beforeExit / SIGINT / SIGTERM, Electron main app before-quit / will-quit, Web pagehide / visibilitychange — and watchUncaughtErrors logs uncaught exceptions / unhandled rejections as fatal before exiting. redirectConsole bridges console.* into the logger.
Where filtering happens
There are three independent gates:
- Logger-level gate —
setLeveldrops entries below a threshold before they enter the pipeline, so cheaply-discarded entries never cost processing. - Pipeline filters — arbitrary predicates (
createLevelFilter,createScopeFilter,createSampler,combineFilters, custom) run inside the pipeline. - Per-transport level — each transport may declare its own
level; entries below it are skipped by that transport only, so one logger can fan out (e.g.error+ to a remote sink while writing everything to a file).
Volume control & sampling
When a log level is hot (e.g. debug/info under load), use the built-in sampler to cap volume. It combines two orthogonal strategies with a logical AND and only applies to the level names you list — every other level passes through untouched:
- probabilistic (
rate, 0..1): keep each entry with probabilityrate. - rate limiting (
maxPerSecond+burst): a token bucket caps throughput, tolerating short bursts of up toburstentries.
Because the sampler is a pipeline filter, sampled entries never reach processors, formatters or transports — it is also the cheapest way to cut cost under load. See examples: controlling volume.
import { createSampler } from 'lograil';
logger.getPipeline().addFilter(
createSampler({ levels: ['debug', 'info'], maxPerSecond: 100, burst: 200 }),
);Why it's structured this way
- Separation: formatting and destination are decoupled — one entry can go to the console and a file with different formats.
- Composability: filters/processors/transports are plain functions/objects, easy to unit test and reuse.
- Extensibility: plugins can reshape the pipeline and add sinks at runtime without touching application code.
- Portability: the runtime adapter means the same API works in Web, Node and Electron, including the renderer→main log aggregation in Electron.