Skip to content

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 LogEntry processing flow. The first argument may be a string, an Error, or any value (objects are kept structured). The entry carries context (persistent, inherited request-scoped data) and metadata (per-entry, usually injected by processors — see Context & Metadata for the distinction).
  • Pipeline runs Filters (returning false drops the entry), then Processors (in order, for enrichment, redaction, serialization, …), then a Formatter that produces the on-wire representation. Built-in processors such as createRedactProcessor and createSerializeProcessor plug in here.
  • Plugins run after the pipeline via an async intercept hook per entry. They can drop an entry (return null) or rewrite it, and they receive a PluginContext to 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's write may 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 own level to filter independently.
  • Runtime Adapter abstracts the environment (Web / Node / Electron), so the same logger code runs everywhere.
  • Context is merged into every entry's context field; scoped loggers get an isolated child context. An ambient (async) context — request-scoped fields kept via AsyncLocalStorage and auto-inherited across await — 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:

ts
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 transports

Because 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:

  1. Logger-level gate — setLevel drops entries below a threshold before they enter the pipeline, so cheaply-discarded entries never cost processing.
  2. Pipeline filters — arbitrary predicates (createLevelFilter, createScopeFilter, createSampler, combineFilters, custom) run inside the pipeline.
  3. 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 probability rate.
  • rate limiting (maxPerSecond + burst): a token bucket caps throughput, tolerating short bursts of up to burst entries.

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.

ts
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.

Released under the MIT License.