@azerothjs/logger
v2.1.0
Published
AzerothJS logger - one zero-dependency logger with two faces: colored, iconed developer output and pino-class NDJSON for production, plus the framework's startup banner
Maintainers
Readme
@azerothjs/logger
AzerothJS logger - one zero-dependency logger with two faces: colored, iconed developer output and pino-class NDJSON for production, plus the framework's startup banner
Part of AzerothJS - the fine-grained fullstack framework. One zero-dependency logger with two faces: colored, iconed developer output on a TTY, and pino-class NDJSON for production - plus the banner every AzerothJS process starts with.
Install
[!NOTE] ESM-only, Node >= 22:
npm install @azerothjs/loggerOverview
import { createLogger } from '@azerothjs/logger';
const log = createLogger({ redact: ['authorization'] });
log.info('server listening', { port: 3000 });
log.warn('slow handler', { route: '/api/search', durationMs: 1203 });
log.error('request failed', { error: new Error('upstream timeout', { cause: reason }) });
const requestLog = log.child({ requestId: 'r-42' }); // context binds once, rides every line
requestLog.debug('cache miss', { key: 'user:7' });The SAME calls render for whoever is watching:
A developer's terminal (TTY, not production) - one designed line per event: a dim
seconds clock, the level icon in its color, the message bold, fields hanging off dim
interpuncts, errors as indented blocks with the cause chain walked:
14:32:07 ● server listening · port=3000
14:32:09 ▲ warn slow handler · route=/api/search · durationMs=1203
14:32:11 ✖ error request failed
at handler (src/routes.ts:42:11)
caused by: Error: upstream timeoutProduction / a pipe / a collector - byte-clean NDJSON, one line per event, stable key order, epoch-millis time, no ANSI ever:
{"level":"info","time":1770000000000,"msg":"server listening","port":3000}
{"level":"warn","time":1770000002421,"msg":"slow handler","route":"/api/search","durationMs":1203}Face selection is automatic (TTY + NODE_ENV !== 'production' = pretty, anything else =
NDJSON) and overridable: createLogger({ face: 'ndjson' }) in code, or AZEROTH_LOG=json,
AZEROTH_LOG=debug, AZEROTH_LOG=pretty:trace from the environment. In a browser, levels
map onto styled console methods.
What the design promises
- A disabled level is free. Below-threshold methods ARE a shared no-op - a
log.trace(...)in a hot loop costs a plain call, so instrumentation can stay in production code. Guard genuinely expensive field construction withlog.enabled('debug'). - Child context is serialized once.
child(fields)pre-renders its bindings into the NDJSON line, so contextual logging (request ids, job ids) costs about what one extra string concat costs - not a re-serialization on every line. - Redaction happens before any sink.
redact: ['authorization', 'cookie']replaces values at the logger, so no formatter, transport, or adapter can leak them. - Errors are first-class. Any
Errorfield serializes as{ name, message, stack }with the fullcausechain (depth-capped against cycles). - The color social contract is honored.
NO_COLORalways wins;FORCE_COLORcolors a pipe for CI viewers; a non-TTY stream is byte-clean; icons degrade to ASCII on terminals that cannot render them.
The pretty face's design
The developer face renders MEANING, not just strings, and its rules are deliberate:
infolines drop the level word - the icon carries it; warn/error keep theirs and read louder by contrast, with their message tinted in the level's color.Semantic values: an
http(s)://URL renders in the brand ice-blue; astatusfield that is a real status code renders as a verdict (2xx green, 3xx cyan, 4xx yellow, 5xx red); everything else stays plain on purpose.Request sentences: a record carrying
method,path,status, anddurationMs- the shape@azerothjs/http'slogRequestsemits - reads as prose, with the method in its REST color:14:32:12 ● GET /healthz → 200 · 0.48ms 14:32:15 ✖ error POST /orders → 500 · 3.1mshide: a context field bound on every line (servicein a single-service dev terminal) is noise to a human and signal to a collector - hide it from THIS face only, every other face and file keeps it:import { createLogger, prettySink } from '@azerothjs/logger'; const log = createLogger({ sink: prettySink({ hide: ['service', 'requestId'] }), fields: { service: 'api' } });
Display only, always: values are styled, never altered, and the NDJSON faces carry every field with full epoch timestamps.
The banner
The framework's face at startup - @azerothjs/http's serve() and the Vite dev plugin
print it, and anything built on AzerothJS can too:
import { printBanner } from '@azerothjs/logger';
printBanner({
version: '1.4.0',
subtitle: 'worker',
entries: [['Queue', 'jobs'], ['Concurrency', '8']],
readyMs: performance.now() - startedAt
}); ▲ AzerothJS v1.4.0 worker
Queue jobs
Concurrency 8
✓ Ready in 12 msprintBanner self-gates: it renders only on an interactive terminal outside production -
a piped or collected stream never carries it. The ready time is whatever YOU measured; the
banner never invents numbers.
Environment
| Variable | Effect |
| --- | --- |
| AZEROTH_LOG | debug (level), json / pretty (face), or both: json:info |
| NO_COLOR | disables all color, everywhere, unconditionally |
| FORCE_COLOR | forces color onto a non-TTY (1/2/3 = 16/256/truecolor) |
| NODE_ENV=production | auto face picks NDJSON and the banner stays silent |
Log files
[!NOTE] The file sinks are Node-only (they use
node:fs/node:path), so they live at the@azerothjs/logger/nodesubpath. The main@azerothjs/loggerentry stays browser-safe - importcreateLoggerand the console/pretty/tee sinks from there, the file sinks from/node.
Point the logger at a file to append forever, or at a FOLDER for day-named files with size rotation and retention - rotation is rename-free (a new name opens, the old file just stops growing), which is what makes it correct on Windows, where an open file cannot be renamed and antivirus loves holding handles:
import { createLogger } from '@azerothjs/logger';
import { fileStream } from '@azerothjs/logger/node';
// logs/app-2026-07-21.ndjson, app-2026-07-21.2.ndjson, ... - NDJSON, never ANSI
const log = createLogger({ stream: fileStream('logs/', { maxFileBytes: 32 * 1024 * 1024, maxFiles: 14 }) });This rides the fused NDJSON fast path untouched. Lines batch in a bounded buffer and
hit disk on a size threshold, a flush interval (default 1 s), flush()/close(), and
process exit - so a process.exit() or a graceful SIGTERM loses nothing, and an
external hard kill loses at most the flush interval. When the disk cannot keep up or a
write fails, lines are DROPPED and counted - never blocking the event loop, never
growing unbounded - with one stderr notice and a log lines dropped record on
recovery: logging must never break the system.
Both faces at once - pretty for eyes, a file for the record. terminalSink() is the
terminal half done right: pretty on a dev TTY (or under AZEROTH_LOG=pretty), NDJSON
when piped or in production. A URL target anchors the folder to your package instead
of the process cwd:
import { createLogger, teeSink, terminalSink } from '@azerothjs/logger';
import { fileSink } from '@azerothjs/logger/node';
const file = fileSink(new URL('../logs/', import.meta.url));
const log = createLogger({ sink: teeSink(terminalSink(), file) });
// on shutdown: file.close() (flushes; process exit also flushes automatically)And with @azerothjs/http, one line per request - pretty on your terminal, NDJSON in
the folder. Compose the tee: a bare stream: fileStream(...) would send EVERYTHING to
the file and leave your terminal silent (a file is not a TTY, so the logger picks its
NDJSON face for it - and no environment variable can repoint an explicit stream):
import { createLogger, teeSink, terminalSink } from '@azerothjs/logger';
import { fileSink } from '@azerothjs/logger/node';
const log = createLogger({ sink: teeSink(terminalSink(), fileSink('logs/')) });
new App({ observe: logRequests(log) });Custom sinks
A sink is one function; everything else is composition:
import { createLogger } from '@azerothjs/logger';
const seen: unknown[] = [];
const log = createLogger({ sink: (record) => seen.push(record) }); // a test spyThe record - { level, message, time, fields } - is the whole contract; an OpenTelemetry
adapter, a test spy, or @azerothjs/http's request logging all consume it structurally.
For files, prefer fileStream/fileSink above - they add the batching, rotation, and
crash-safety a bare write stream does not have.
Related
Part of the AzerothJS monorepo. Related packages:
@azerothjs/http (whose request logging consumes this record shape),
@azerothjs/ws (WebSockets), and @azerothjs/cron (scheduling).
