LoggerJS Architecture
Status: implementation architecture for the current v1-oriented codebase. Source inputs:
DESIGN.md,log.md, and the current monorepo skeleton.
LoggerJS is an isomorphic structured logger for browser, Node, Bun, Deno, and edge runtimes. The product architecture is built around three user-facing concepts:
- Integration: opt-in automatic collection, such as browser console capture, global script errors, HTTP errors, page lifecycle flush, Node process errors, and runtime diagnostics.
- Middleware: synchronous record transforms and filters, such as redaction, sampling, tag/type enrichment, request correlation, dedupe, and route-specific policies.
- Transport: the destination boundary, such as console, stdout, file, HTTP batch, OTLP, Sentry, DB, worker-hosted delivery, or any user custom sink.
There is one additional technical boundary that must stay first-class: Codec. A codec belongs to a transport and owns serialization/deserialization. Middleware must not serialize records. Console transport should preserve raw values. HTTP/file/OTLP transports choose the codec they need.
Current Repository Baseline
The current repo now has the main v1 building blocks in place:
packages/core Logger, LogRecord helpers, LogEvent projection, context, typed events, codecs, console/memory/batch transports
packages/browser Browser HTTP transport, offline queue, beacon/page lifecycle flush, console/error/fetch/XHR integrations
packages/node stdout/stderr/file/http/worker transports, AsyncLocalStorage context, process and diagnostics-channel integrations
packages/processors redact/sample/tags/type/dedupe/trace processors
packages/codecs fixed-shape JSON, built-in msgpackr, projector codec
packages/otel OTLP JSON mapping, HTTP transport, active span trace processor
packages/sentry Sentry structured logs, breadcrumbs, exception/message transport
examples/* browser and node basic demosRemaining architecture work is mostly about polish and package topology:
Processoris still supported as compatibility vocabulary whileMiddlewareis the public mental model.LogEventremains the transport-facing compatibility envelope while the hot path constructsLogRecordand projects when needed.- Coarse browser/node packages can remain as presets, but stable v1 packages should split platform transports and integrations into smaller installable units.
- The current dual ESM/CJS output is retained for compatibility. Declaration output is NodeNext-compatible and public subpath exports are verified.
- Batch transports now cover bounded queues, byte limits, retry, drop counters, circuit breaking, pagehide/beacon behavior, and runtime flush semantics.
Non-Negotiable Design Rules
- Core is platform-neutral.
@loggerjs/coremust not import browser, Node, Bun, Deno, worker, filesystem, fetch, or diagnostics APIs. - Disabled logging is almost free. A disabled level call must do one numeric level comparison and return before record allocation, message stringification, context merge, or integration work.
- Serialization happens only at the transport boundary. The pipeline keeps raw references.
resolveMessage(record)is the only allowed middleware-triggered lazy evaluation. - Middleware is synchronous. No promises, no Koa-style
next, and no async lookup in the hot path. - Integrations use the same pipeline as manual logs. Automatic records differ only by
source; they still pass through middleware, routing, batching, codec, and transport policy. - Integrations are explicit and reversible. Any monkey patch must be opt-in, idempotent, guarded against reentry, and fully torn down.
- Logger errors never escape to application code. Internal failures are counted and reported through a rate-limited meta logger.
- No object pool in v1. Short-lived records should stay young-generation GC objects unless benchmarks prove otherwise.
End-To-End Pipeline
manual API / integration capture
|
v
level gate
|
v
create LogRecord
|
v
global middleware
|
v
transport router / fan-out
|
+--> per-transport middleware
| |
| v
| transport buffer
| |
| v
| codec.encode(batch)
| |
| v
| sink: console / stdout / file / HTTP / OTLP / worker / custom
|
+--> ...The record must never be stringified before the chosen transport is ready to ship. This is what lets console preserve interactive objects, HTTP choose JSON or binary, file choose NDJSON, and OTLP choose its wire mapping without penalizing other destinations.
Core Record Model
The target internal record is LogRecord, not the current LogEvent envelope. It is optimized for a stable hidden class and transport-owned projection:
export interface LogRecord {
time: number;
level: number;
category: readonly string[];
msg: string | null;
lazy: (() => string) | null;
props: Record<string, unknown> | null;
err: unknown;
ctx: BoundContext | null;
source: string;
stack: string | null;
seq: number;
}Implementation rules:
- Construct records through a single
createRecord()path. - Assign every field in the same order, including
nullfields. - Do not
deletefields or attach ad hoc properties to the record. - Extra data belongs in
propsor immutablectx. timeisDate.now(); ordering within equal timestamps isseq.errstays separate frompropsbecause error encoding, stack truncation, cause handling, and dedupe are specialized.
The current LogEvent shape can remain temporarily as a codec projection or compatibility type, but it should not drive the hot path once the v1 rewrite starts.
Logger API
LoggerJS supports two acquisition models:
const log = createLogger({
category: "app",
level: "info",
transports: [consoleTransport()]
});const log = getLogger(["library", "parser"]);
await configure({
middleware: [redact({ paths: ["password", "*.token"] })],
transports: {
console: consoleTransport(),
http: httpTransport({ url: "/v1/logs", codec: jsonCodec() })
},
loggers: [
{ category: ["app"], level: "debug", transports: ["console", "http"] },
{ category: ["library"], level: "warn", transports: ["http"] }
],
integrations: [consoleIntegration(), globalErrorsIntegration()]
});Required call forms:
log.info("user logged in", { userId: 42 });
log.error(err, "save failed", { orderId });
log.debug(() => expensiveDebugMessage());
log.event(CheckoutCompleted, { orderId, amountCents });
log.child({ requestId }).warn("retrying");
await log.flush();The overload rule stays small:
- first arg
string: message - first arg
function: lazy message - otherwise: error slot, with optional message and props
No printf-style formatting belongs in core. Structured fields are first-class; formatting is a display concern.
Registry And Configuration
getLogger(category) exists for library authors. Before configuration, it returns a void logger. After configure(), it routes through the configured pipeline.
Configuration requirements:
- prefix matching by category, where
["app"]applies to["app", "checkout"] - named transports and named middleware
- explicit integration lifecycle management
- optional early ring buffer for pre-config logs
configure({ reset: true })to replace old transports and call async disposal hooks- immutable runtime snapshots so hot path reads do not traverse mutable config structures
This registry is a strategic feature: it lets third-party libraries log without coupling to any backend or forcing application configuration.
Context
There are two context modes:
- Explicit context via
logger.child(bindings). Child bindings are flattened and frozen at child creation time. - Implicit context via
withContext(bindings, fn). Node/Bun/Deno use AsyncLocalStorage or equivalent conditional exports. Browser initially degrades to synchronous-scope context until TC39 AsyncContext is viable.
Codec-level context optimization replaces pino-style global chindings:
interface EncodeContext {
levelName(level: number): string;
ctxCache: WeakMap<object, unknown>;
schemaCache: WeakMap<object, unknown>;
}Each codec may cache encoded fragments for immutable bound contexts. This preserves the performance benefit without making JSON serialization a global logger concern.
Middleware
Target interface:
export interface Middleware {
readonly name: string;
process(record: LogRecord): LogRecord | null;
}Execution model:
- global middleware runs once before fan-out and may mutate the single record in place
- per-transport middleware runs after fan-out and must treat the record as shared
- per-transport changes use
cloneRecord(record, patch)to preserve shape and avoid cross-transport leakage - returning
nulldrops the record - middleware exceptions are caught, counted, and do not stop the remaining pipeline unless the middleware explicitly drops
Built-ins should cover:
redact: safe path/key redaction with copy-on-write on matched branchessample: level/category/key-based sampling, with error and fatal defaulting to full retentionrateLimit: token bucket by category/level/sourcededupe: fingerprinted burst collapsefingersCrossed: low-level ring buffer released by an error triggerenrich: synchronous props/context enrichmenttagsandtype: thin compatibility helpers for current processor behaviortraceContext: OTel or user-provided trace/span injection
Middleware must not call JSON.stringify, String(record.props), or recursively normalize whole records. If it needs a message, it must call resolveMessage(record) intentionally.
Transports
Target interface:
export interface Transport {
readonly name: string;
write(record: LogRecord): void;
flush(): Promise<void>;
flushSync?(): void;
dispose(): Promise<void>;
filter?(record: LogRecord): boolean;
middleware?: Middleware[];
}Transport responsibilities:
- final routing filters
- queue and backpressure policy
- batching
- retry and circuit breaking
- codec selection and serialization
- destination-specific delivery
- drop/error counters
- flush and disposal semantics
Batching Base
The shared batching implementation should support:
maxRecordsmaxBytesmaxWaitMsconcurrency- retry with exponential backoff and full jitter
drop-oldanddrop-new- drop counters and hooks
- circuit breaker with half-open recovery
- no idle timer when the queue is empty
- encoded-size accounting at ship time
The current batchTransport() is acceptable as a bootstrap utility, but it is not the v1 reliability layer.
Console Transport
Console transport should not serialize in pretty mode. It should pass raw msg, props, and err references to the original console methods so browser devtools keep object inspection.
It must use the unpatched console registry so it can coexist with console capture without feedback loops.
HTTP Transport
HTTP transport is a shared abstraction with platform implementations:
- Browser:
fetch,keepalive,sendBeacononpagehide/visibilitychange, optional IndexedDB offline queue, and strict payload limits around the 64 KiB beacon budget. - Node: global
fetch/undici, retry/circuit breaker, and no claim of sync crash flush. - Edge:
waitUntilhook for response-lifetime-safe delivery.
Privacy defaults:
- no request/response body collection
- no headers unless allowlisted
- no offline disk persistence unless explicitly enabled
File And Stdout Transports
Node stdout/stderr/file transports should default to NDJSON. File transport needs a real flushSync() path using fs.writeSync or an equivalent crash-safe primitive. Async stream writes alone are not enough for fatal process events.
Worker Transport
Node worker transport should move IO and retry state off the main thread. The preferred path is:
main thread batch -> codec.encode(batch) -> Uint8Array -> postMessage(buffer, [buffer])If the worker fails, transport should degrade to inline mode and emit a meta warning. flushSync remains unavailable across worker boundaries.
OTLP And Sentry
OTLP/HTTP JSON is a first-party transport because LoggerJS should integrate with existing observability backends rather than invent a logging backend protocol.
Sentry support should be an adapter package. LoggerJS maps records to Sentry structured logs and optionally captures error records as Sentry events.
Codecs
Target interface:
export interface Codec<Out extends string | Uint8Array = string | Uint8Array> {
readonly name: string;
readonly contentType: string;
encode(batch: readonly LogRecord[], ctx: EncodeContext): Out;
decode?(data: Out): unknown[];
}Required codecs:
jsonCodec: default NDJSON/log JSON codec with fixed-field ordering, nativeJSON.stringifyfor ordinary props, and safe fallback only for failing branches.structuredCodec: rich value preserving codec with symmetric decode for Error, cause chains, AggregateError, circular/shared references, BigInt, Date, RegExp, URL, Map, Set, TypedArray, ArrayBuffer,undefined,NaN, infinities, and-0.msgpackCodec: binary batch codec, either a benchmark-proven custom subset or a small adapter overmsgpackr.projectorCodec: utility adapter for custom wire schemas.
The structured codec should use a flat value-pool format, not eval, new Function, or recursive revivers. Decode should be JSON.parse plus a deterministic pointer restoration pass, making it CSP-friendly and suitable for browser replay tools.
Integrations
Target interface:
export interface Integration {
readonly name: string;
setup(api: IntegrationAPI): Teardown;
}
export interface IntegrationAPI {
capture(input: CaptureInput): void;
getLogger(category: string | readonly string[]): Logger;
unpatched: UnpatchedRegistry;
guard<T extends (...args: any[]) => any>(fn: T): T;
}Loop prevention has three layers:
- Register original console/fetch/XHR functions before patching.
- Guard synchronous logger execution so reentrant capture is dropped and counted.
- Preserve
record.sourceand let transports filter self-generated records.
Required browser integrations:
- console capture for
log,info,warn,error,debug, andtrace - global script/resource errors
unhandledrejection- optional
securitypolicyviolation - fetch and XHR HTTP error collection
- page lifecycle flush hooks
- optional offline replay hooks
Required Node integrations:
uncaughtExceptionunhandledRejectionwarningbeforeExit/exitflush handling- diagnostics_channel subscriptions for undici and Node HTTP where available
Node crash behavior must be honest. If exitOnUncaught is enabled, fatal capture should flush sync-capable transports, attempt bounded async flush for the rest, and then preserve process exit semantics. The integration must not silently turn fatal crashes into zombie processes.
Routing
Routing uses category, level, source, tags/type, and explicit transport filters.
Configuration should support:
- category prefix rules
- per-transport minimum levels
- source exclusions such as excluding
integration:consolefrom console transport - per-transport middleware
- named routes that can be reused in presets
Routing must be resolved into immutable runtime snapshots so each log call does not perform expensive dynamic config lookup.
Performance Budget
Initial internal budget:
| Path | Target |
|---|---|
| Disabled level call | one numeric comparison, zero allocation |
| Enabled record to queue, 3 middleware, no stack | <= 1 microsecond per record on mainstream desktop CPU |
| JSON/NDJSON codec | million-records-per-second class for ordinary objects |
| Node NDJSON full path | at least 80% of pino for equivalent output before v1 |
| Core size | <= 4 KB min+gzip (aspirational target, not yet met — see note below) |
| Record allocation | one record object; no data copy unless middleware explicitly clones |
Status as of 2026-06: the <= 4 KB core-size row above is an unmet aspiration, not the current state. Measured today (and enforced by pnpm size:check): the full @loggerjs/core barrel is ~18 KB gzip, and a minimal tree-shaken import (createLogger + a consoleTransport) is ~6 KB min+gzip. Keep public size wording anchored to these measured numbers until the budget is actually met.
Benchmarking must cover Node and real browsers, not only synthetic Node loops. The suite should compare pino, winston, LogTape, native console, native JSON.stringify, current LoggerJS, and target LoggerJS paths.
Decision: keep the record pipeline; optimize through codec-owned preparation
Status as of 2026-06: on the reference machine (Apple M1 Max, Node v22.21.1), measured with the drift-canceling paired A/B harness, the lean Node NDJSON path runs at ~1.19x pino and the codec-owned prepared lean path at ~1.28x — i.e. faster than pino for equivalent output. The full-envelope path is ~0.9x pino while emitting id, seq, and levelName on top of pino's fields (see docs/BENCHMARKS.md). This ranking is CPU/Node-V8 dependent: pino and loggerjs both use hand-tuned JSON hot paths, and the current docs treat the difference as an empirical benchmark result rather than assigning a low-level cause. On a different chip or Node/V8 build pino can lead. The point is that loggerjs reaches pino's class without moving serialization into the logger.
Getting here took a 2026-06 profiling pass that also corrected an earlier overstatement ("the gap is structural, not unoptimized code"). Three changes, none of which touch the architecture, moved the lean ratio from ~1.30x pino to ~0.84x on this machine: (1) getContext no longer runs an addedProviders.map() + spread + mergeContext({}) on every call when no ambient context is configured (it had been allocating three objects to merge nothing); (2) fastEventJsonCodec bakes its includeX toggles once at codec creation and emits the header in a single template instead of a chain of += concatenations (stable decisions happen outside the per-record call); and (3) codec-owned prepared record encoders let transports reuse logger/category/tags fragments without making the logger own JSON serialization.
LoggerJS still allocates a LogRecord per log so middleware, processors, integrations, and multiple transports can observe one shared value, and the codec still owns a never-throw safe-fallback contract — and it now matches or beats pino on tested hardware anyway. A fusion fast path that bypasses the record whenever a logger has exactly one sync transport and no middleware is therefore rejected as the default, with even less reason than before, because it would:
- create a performance cliff where adding the first middleware silently costs 30%+ of throughput,
- move serialization into the logger, breaking the codec-belongs-to-transport boundary,
- and double the hot-path surface that every semantic change must keep in sync (the id-drift and source round-trip bugs fixed in 2026-06 were exactly this class of dual-path defect).
Remaining performance budget goes to the default paths (batch enqueue, default codecs, prepared codec contracts) and to regression gating, not to fusion-only peak numbers. Revisit only if a use case demonstrates that a separate semantic hot path matters in production.
Reliability
Default semantics are best-effort at-most-once. LoggerJS must not block application progress indefinitely to guarantee log delivery.
Every loss path must be observable:
- queue overflow
- batch too large
- retry exhausted
- circuit breaker open
- beacon failed
- offline queue quota exceeded
- flush deadline exceeded
- integration loop guard drop
- middleware/transport exception
These counters should be available through the meta logger and optional stats APIs.
Privacy And Security
Defaults:
- redact common sensitive keys: authorization, cookie, set-cookie, password, passwd, token, secret, apiKey, api_key, and
*_key - fetch/XHR integrations do not capture bodies
- fetch/XHR integrations do not capture headers unless allowlisted
- browser offline queue is disabled by default
- no
evalor generated code in default builds - no runtime dependencies in core
Any feature that writes logs to durable browser storage must be explicit because it changes the application's privacy posture.
Testing Strategy
Required test layers:
- unit tests for core record construction, level gate, overloads, child context, middleware, router, and transport errors
- codec property tests for safe JSON and structured round-trip behavior
- loop prevention tests with console transport and console integration enabled together
- browser Playwright tests for pagehide/beacon flush, fetch/XHR capture, global errors, and offline queue behavior
- Node child-process tests for uncaught exception flush and exit semantics
- runtime smoke tests for Node, Bun, Deno, and workerd/miniflare
- size-limit checks for core and integration packages
- benchmark regression checks with explicit thresholds
No milestone is complete without examples, tests, and at least one benchmark or size measurement relevant to the changed layer.
Package Direction
The v0 package layout can support development, but the v1 public layout should move toward:
@loggerjs/core
@loggerjs/transport-http
@loggerjs/transport-otlp
@loggerjs/transport-file
@loggerjs/transport-worker
@loggerjs/codec-structured
@loggerjs/codec-msgpack
@loggerjs/integration-console
@loggerjs/integration-global-errors
@loggerjs/integration-fetch
@loggerjs/integration-node
@loggerjs/otel
@loggerjs/sentry
@loggerjs/pretty
@loggerjs/browser preset/meta package
@loggerjs/node preset/meta packagePreset packages are allowed, but ownership of platform APIs should live in small packages so users can install only the collection and transport surface they need.
Completion Criteria For v1
LoggerJS reaches v1 readiness when:
- core public API is locked by API report
- disabled hot path and enabled queue path meet budget on Node and browser
- codec JSON, structured, and msgpack paths have benchmark data
- browser and Node integrations have loop and teardown tests
- OTLP collector demo works end to end
- crash flush behavior is tested by child process
- privacy defaults are documented and tested
- examples cover browser, Node service, edge worker, and OTLP collector
- migration guide exists for console.log, pino, winston, and LogTape-style library logging