@swarmmachina/swm-logs
v0.1.1
Published
Zero-dependency structured JSON logger for Node.js
Readme
@swarmmachina/swm-logs
A zero-runtime-dependency structured JSON logger for Node.js. It emits one newline-delimited JSON record per call to stdout, stderr, a file descriptor, a writable destination, or lifecycle-aware transports.
Features
- Stable NDJSON envelope - Numeric
level, epoch-millisecondtime, optionalmsg, bindings, and call fields. - Pino-compatible severities -
trace=10throughfatal=60, plus explicit custom levels. - Cheap child loggers - Bindings are serialized once during
child()construction and output state is shared. - Safe serialization - Circular references,
BigInt, bounded object depth, bounded edge count, and recursiveError.causehandling. - Compiled redaction - Exact, wildcard, dot, and bracket paths with censor and removal modes.
- Fast default path - Manual JSON encoding without a record object, callback arrays, or runtime dependencies.
- Opt-in extensions - Keyed serializers, structured hooks, formatters,
transports, and a reversible
console.*bridge. - Explicit delivery lifecycle - Immediate output by default, bounded opt-in buffering, synchronous crash flushing, and asynchronous transport close.
- Observable failures - Contained destination errors, loss counters, and a synchronous failure observer.
- Modern package surface - Native ESM and TypeScript declarations for Node.js 22 and 24.
Installation
pnpm add @swarmmachina/swm-logsRuntime requirements
- Node.js
22.xor24.x; other majors are rejected by the package engine constraint. - Native ESM. CommonJS
require()is not a supported package surface. - Zero runtime dependencies. Transports, workers, file rotation, network clients, and vendor exporters are intentionally not bundled.
Quick Start
import Logger from '@swarmmachina/swm-logs'
const logger = new Logger({ bindings: { service: 'gateway' } })
logger.info({ port: 3000 }, 'listening')
logger.error(new Error('request failed'))The first call emits one line shaped like this:
{ "level": 30, "time": 1710000000000, "msg": "listening", "service": "gateway", "port": 3000 }time is Unix epoch milliseconds. Every record ends with exactly one newline.
The default field order is envelope, pre-serialized bindings, then call fields.
Application fields named level or time are ignored. A msg field is used as
the message only when no explicit message argument is present.
API Documentation
Exports
export { default, ConsoleBridge, LEVELS, Logger, Logger as NamedLogger } from '@swarmmachina/swm-logs'
export type { DeliveryStats, LoggerOptions, LogRecord, LogTransport, RedactOptions } from '@swarmmachina/swm-logs'The default and named Logger exports refer to the same class. LEVELS is a
frozen map of the six built-in numeric severities.
new Logger(options?)
Construction validates the complete configuration. Invalid levels, paths,
callbacks, destinations, or bounds throw a synchronous TypeError before the
logger becomes visible to the application.
| Option | Default | Purpose |
| -------------------- | ------------ | ------------------------------------------------------------------ |
| level | 'info' | Minimum enabled severity; 'silent' disables output. |
| customLevels | none | Additional name-to-number severity map. |
| bindings | {} | Root fields serialized once during construction. |
| redact | none | Path list or rich censor/removal configuration. |
| serializers | none | Top-level field serializers keyed by field name. |
| buffering | false | true for defaults or a bounded console buffer configuration. |
| console | true | Deliver to destination in addition to transports. |
| destination | 'stdout' | 'stdout', 'stderr', a numeric fd, or an object with write(). |
| transports | [] | Fire-and-forget delivery owners. |
| hooks | none | Synchronous beforeFormat and afterFormat hooks. |
| formatter | JSON encoder | Custom record-to-string encoder. |
| onDestinationError | none | Synchronous observer for contained delivery failures. |
| time | Date.now | Epoch-millisecond clock, useful for deterministic tests. |
| errorCauseDepth | 5 | Maximum number of errors retained from a cause chain. |
| depthLimit | 5 | Maximum retained nested object/array depth. |
| edgeLimit | 100 | Maximum retained properties/elements per container. |
console: false requires at least one transport. A custom destination or
console buffering cannot be combined with console: false.
Log methods
Every built-in or custom level accepts the same call shapes:
logger.info('message')
logger.info({ requestId: 'r1', ok: true }, 'message')
logger.error(error)
logger.error({ err: error, requestId: 'r1' }, 'message')
logger.info('user %s has %d jobs', 'Ada', 3)
logger.log('notice', { deploymentId: 'd1' }, 'deployed')trace(), debug(), info(), warn(), error(), fatal(), and log()
return void. fatal() writes severity 60; it does not terminate the process.
Serialization, clock, hook, formatter, and destination failures do not escape a
log method. A processing failure becomes a valid logger_error record.
| Name | Value |
| ------- | ----: |
| trace | 10 |
| debug | 20 |
| info | 30 |
| warn | 40 |
| error | 50 |
| fatal | 60 |
Read or change the threshold through logger.level. Use
logger.isLevelEnabled(level) before expensive field construction. An unknown
level returns false; an invalid dynamic log() level produces a contained
logger_error record.
Custom levels are called through log() rather than generated instance
methods:
const customLevels = { notice: 35 } as const
const logger = new Logger({ customLevels, level: 'trace' })
logger.log('notice', 'deployed')Child loggers
Create one child per request, job, connection, or other bounded lifecycle and reuse it for all records in that scope:
import Logger from '@swarmmachina/swm-logs'
const rootLogger = new Logger({ bindings: { service: 'realtime-api' } })
/**
* Creates the logger for one request lifecycle.
* @param {{ requestId: string, remoteAddress: string }} context
* @returns {Logger}
*/
export function requestLogger(context) {
return rootLogger.child({ requestId: context.requestId, remoteAddress: context.remoteAddress })
}
const logger = requestLogger({ requestId: 'r1', remoteAddress: '127.0.0.1' })
logger.info('accepted')child(bindings, { level? }) pre-serializes new bindings and shares levels,
serializers, extensions, destination, transports, buffer, and delivery counters
with the root. Later mutation of a nested binding does not change emitted
output. bindings() returns a shallow copy of effective binding values.
The output lifecycle is also shared. Call close() once from the component that
owns the root logger; closing through a child affects the same transports and
buffer.
Lifecycle and diagnostics
flush()sends buffered console output and awaits transportflush()methods when present.flushSync()uses synchronous capabilities only. Use it on fatal paths where asynchronous cleanup cannot be awaited.close()flushes/releases the shared console buffer and coordinates all transportclose()methods concurrently.deliveryStats()returns a detached snapshot withdestinationErrors,droppedBytes,droppedChunks, anddroppedRecords.
flush() and close() return void when every output is synchronous and a
Promise<void> when asynchronous transports participate. await works for
both forms.
Serialization and redaction
Primitive fields use a manual encoder. Nested values follow JSON semantics with these finite-safety extensions:
- circular ancestor references become
"[Circular]"; BigIntvalues become decimal strings;undefined, functions, symbols, and symbol keys are omitted;- non-finite numbers become
null; errvalues includetype,message,stack, recursivecause, and enumerable custom properties;- values beyond
depthLimitoredgeLimitbecome finite markers.
Top-level serializers run only for matching keys. Binding serializers run once when a root or child is constructed; call-field serializers run once for every enabled call.
import Logger from '@swarmmachina/swm-logs'
const logger = new Logger({
time: () => 1710000000000,
serializers: {
account: (account) => ({ id: account.id })
}
})
logger.info({ account: { id: 7, accessToken: 'secret' } }, 'signed in')
// {"level":30,"time":1710000000000,"msg":"signed in","account":{"id":7}}Redact paths support dot notation, quoted or numeric bracket notation, *, and
[*]. A path list replaces matches with "[Redacted]". Rich configuration can
provide a static/function censor or set remove: true.
import Logger from '@swarmmachina/swm-logs'
const logger = new Logger({
time: () => 1710000000000,
redact: ['req.headers.authorization', 'users[*].password']
})
logger.info(
{
req: { headers: { authorization: 'Bearer secret' } },
users: [{ id: 7, password: 'secret' }]
},
'request'
)
// {"level":30,"time":1710000000000,"msg":"request","req":{"headers":{"authorization":"[Redacted]"}},"users":[{"id":7,"password":"[Redacted]"}]}A single supported path is fused with bounded serialization. Multiple and overlapping paths use a compiled branch trie and copy only matched containers; caller-owned data is not mutated. Wildcard work grows with the number of properties or elements at the matched level, so prefer exact paths for known schemas.
Hooks, formatters, and transports
The opt-in extension flow is:
arguments → prepared record → beforeFormat → formatter → afterFormat → console + transportsWith no hooks and no formatter, the logger does not allocate LogRecord objects
or callback arrays. Enabling either selects the structured path for that root
and its children.
beforeFormatreceives an owned record after serializers and redaction. It may mutate top-level state or returnfalseto drop the record.formatterreturns a string. A trailing newline is added when absent.afterFormatcan observe or replace the line, or returnfalseto drop it.- Hook/formatter failures become
logger_errorrecords.
Transports are stateful delivery owners and should be classes when they own a queue, socket, worker, file, or timer:
import Logger from '@swarmmachina/swm-logs'
class MemoryTransport {
#lines = []
write(line, level) {
this.#lines.push({ level, line })
}
records() {
return this.#lines.map(({ line }) => JSON.parse(line))
}
close() {
this.#lines = []
}
}
const transport = new MemoryTransport()
const logger = new Logger({
console: false,
hooks: {
beforeFormat(record) {
record.fields.service = 'gateway'
}
},
transports: [transport]
})
logger.info('listening')
await logger.close()LogTransport.write(line, level) is a fire-and-forget acceptance boundary. It
must enqueue or accept quickly; blocking I/O blocks the event loop. Each
transport owns queue bounds, batching, backpressure, retry limits,
idempotency/deduplication, timeouts, persistence, and asynchronous failure
metrics. A synchronous transport throw is contained and does not prevent later
outputs from receiving the record.
console defaults to true, so transports normally receive each record in
addition to stdout or the configured destination. Console buffering never
delays transport delivery.
For low-volume direct delivery, HTTP and PostgreSQL can be separate resource
owners. Keep each class in its own application module; they are shown together
here only to document the complete transport contract. The PostgreSQL example
uses the application-owned dependency pg (pnpm add pg), not a dependency of
this package.
import { randomUUID } from 'node:crypto'
export class HttpTransport {
#closed = false
#endpoint
#failures = 0
#onError
#pending = new Set()
#timeoutMs
constructor({ endpoint, onError, timeoutMs = 2000 }) {
if (typeof onError !== 'function') {
throw new TypeError('onError must be a function')
}
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
throw new TypeError('timeoutMs must be a positive safe integer')
}
this.#endpoint = new URL(endpoint).href
this.#onError = onError
this.#timeoutMs = timeoutMs
}
write(line, level) {
if (this.#closed) {
throw new Error('HttpTransport is closed')
}
this.#track(this.#send(randomUUID(), line, level))
}
flush() {
return Promise.all([...this.#pending]).then(() => undefined)
}
close() {
this.#closed = true
return this.flush()
}
stats() {
return { failures: this.#failures, pendingRequests: this.#pending.size }
}
async #send(eventId, line, level) {
const response = await fetch(this.#endpoint, {
method: 'POST',
headers: {
'content-type': 'application/x-ndjson',
'idempotency-key': eventId,
'x-log-level': String(level)
},
body: line,
signal: AbortSignal.timeout(this.#timeoutMs)
})
try {
if (!response.ok) {
throw new Error(`log endpoint returned HTTP ${response.status}`)
}
} finally {
await response.body?.cancel()
}
}
#track(work) {
const pending = work.catch((error) => this.#report(error))
this.#pending.add(pending)
void pending.then(() => this.#pending.delete(pending))
}
#report(error) {
this.#failures += 1
try {
this.#onError(error)
} catch {
// Failure reporting must not create an unhandled rejection.
}
}
}The PostgreSQL destination stores one complete JSON record per query. Values are parameterized; application data is never interpolated into SQL:
CREATE TABLE app_logs (
event_id uuid PRIMARY KEY,
level integer NOT NULL,
record jsonb NOT NULL
);import { randomUUID } from 'node:crypto'
import { Pool } from 'pg'
export class PostgresTransport {
#closed = false
#closePromise
#failures = 0
#onError
#pending = new Set()
#pool
constructor({ connectionString, onError }) {
if (typeof onError !== 'function') {
throw new TypeError('onError must be a function')
}
this.#onError = onError
this.#pool = new Pool({
connectionString,
max: 4,
connectionTimeoutMillis: 2000,
statement_timeout: 2000,
query_timeout: 2500
})
this.#pool.on('error', (error) => this.#report(error))
}
write(line, level) {
if (this.#closed) {
throw new Error('PostgresTransport is closed')
}
this.#track(
this.#pool.query(
`INSERT INTO app_logs (event_id, level, record)
VALUES ($1::uuid, $2::integer, $3::jsonb)`,
[randomUUID(), level, line]
)
)
}
flush() {
return Promise.all([...this.#pending]).then(() => undefined)
}
close() {
if (this.#closePromise !== undefined) {
return this.#closePromise
}
this.#closed = true
this.#closePromise = this.flush().then(() => this.#pool.end())
return this.#closePromise
}
stats() {
return {
failures: this.#failures,
pendingQueries: this.#pending.size,
poolWaitingCount: this.#pool.waitingCount
}
}
#track(work) {
const pending = work.catch((error) => this.#report(error))
this.#pending.add(pending)
void pending.then(() => this.#pending.delete(pending))
}
#report(error) {
this.#failures += 1
try {
this.#onError(error)
} catch {
// Failure reporting must not create an unhandled rejection.
}
}
}Either transport can be used alone, or both can receive independent copies:
/* global HttpTransport, Logger, PostgresTransport */
const onError = (error) => process.stderr.write(`[log-delivery] ${String(error)}\n`)
const httpTransport = new HttpTransport({ endpoint: process.env.LOG_ENDPOINT, onError })
const postgresTransport = new PostgresTransport({ connectionString: process.env.DATABASE_URL, onError })
const logger = new Logger({ console: false, transports: [httpTransport, postgresTransport] })
logger.info({ requestId: 'r1' }, 'accepted')
await logger.close()These transports intentionally have no batching, retry queue, or backpressure
bound. write() starts one asynchronous operation per record, so the in-flight
sets and the PostgreSQL pool wait queue can grow without limit under sustained
load. Use them only for low-rate delivery; export stats(), alert on failures
and growing pending counts, and move high-volume or durable delivery to a
bounded queue, WAL, outbox, or external logging agent. A final HTTP/SQL failure
is counted and the record is dropped. Fan-out to both destinations is not
transactional: one can succeed while the other fails.
Output, backpressure, and shutdown
Immediate mode calls process.stdout.write(line) by default and adds no logger
queue. If a Node.js stream returns false, the runtime/stream owns its pending
bytes and logging continues. That signal is backpressure, not a delivery error;
a persistently slow pipe may therefore grow stream-owned memory.
For a strict application-level memory bound, use a transport with a bounded queue and an explicit overflow policy. Decide whether overflow blocks the producer, drops newest/oldest records, or persists to a WAL, and export those decisions as metrics.
stdout backed by a regular file is synchronous on supported Node.js platforms;
piped stdout may be asynchronous. When crash durability is mandatory, use a
regular file or a numeric descriptor and call flushSync() on the fatal path.
Buffered console output
import Logger from '@swarmmachina/swm-logs'
const logger = new Logger({
buffering: { maxBytes: 64 * 1024, flushInterval: 1000, flushLevel: 'warn' }
})
logger.info('batched')
logger.warn('flushes the whole console buffer')
process.once('beforeExit', () => logger.flushSync())The buffer flushes at maxBytes, on an unref'ed interval, or at flushLevel
and above. Defaults are 64 KiB, 1000 ms, and warn. It is reset even when the
destination throws, preserving the configured bound; one oversized record can
temporarily exceed that bound.
flushSync() uses fs.writeSync() when a numeric descriptor is known. A
generic writer without fd can only receive its ordinary write() call, which
the logger cannot make durable on its behalf.
Graceful shutdown
The application owns the root lifecycle. Stop accepting work first, wait for in-flight operations, then close the logger:
/* global rootLogger, server */
let shutdownPromise
/**
* Drains application work and the root logger once.
* @param {string} signal
* @returns {Promise<void>}
*/
function shutdown(signal) {
if (shutdownPromise !== undefined) {
return shutdownPromise
}
shutdownPromise = (async () => {
rootLogger.info({ signal }, 'shutdown started')
await server.stopAccepting()
await server.drain(10_000)
await rootLogger.close()
})()
return shutdownPromise
}
/**
* Reports a failed signal-driven shutdown.
* @param {string} signal
*/
function handleSignal(signal) {
void shutdown(signal).catch((error) => {
rootLogger.fatal({ err: error }, 'shutdown failed')
rootLogger.flushSync()
process.exitCode = 1
})
}
process.once('SIGTERM', () => handleSignal('SIGTERM'))
process.once('SIGINT', () => handleSignal('SIGINT'))Put timeouts around external transport cleanup; the logger cannot impose a
deadline on user-owned flush() or close() implementations. For fatal
exceptions where asynchronous work is unsafe, call flushSync() and let the
supervisor restart the process.
Delivery failures
Destination and synchronous transport failures are contained. Install
onDestinationError to export an alert and inspect deliveryStats() for loss:
const logger = new Logger({
onDestinationError(event) {
deliveryFailureCounter.add(1, {
operation: event.operation,
droppedRecords: event.droppedRecords
})
}
})
logger.info('delivery monitoring enabled')The observer is synchronous, reentrancy-guarded, and its own failures are contained. Keep it non-blocking and do not use it as a second delivery path.
Console bridge
ConsoleBridge patches only the supplied console instance and owns the exact
methods needed to restore it.
import Logger, { ConsoleBridge } from '@swarmmachina/swm-logs'
const logger = new Logger()
const bridge = new ConsoleBridge(logger).install()
console.log('structured now')
bridge.restore()trace, debug, log, info, warn, and error map to the corresponding
logger methods; console.log maps to info. Repeated install() and
restore() calls are safe. Restoration does not overwrite another patch that
replaced a method after installation.
Migrating from pino
The default output works with NDJSON pipelines and parsers that recognize pino numeric levels.
| pino | @swarmmachina/swm-logs |
| ---------------------------------- | ------------------------------------------- |
| pino({ level }) | new Logger({ level }) |
| base | bindings |
| customLevels | customLevels + log(name, ...) |
| redact path list | redact path list |
| timestamp fragment function | time returning epoch milliseconds |
| synchronous destination | immediate mode (default) |
| asynchronous destination | buffering or an owned transport |
| transports / worker pretty printer | transports[]; implementations not bundled |
| custom serializers | keyed serializers plus built-in err |
| hooks / formatters | opt-in structured extension pipeline |
Object-field ordering is intentionally envelope-first, with msg before
bindings and call fields. Compatibility tests compare complete output bytes;
do not rely on another logger's default key order during migration.
Performance
Performance numbers are hardware, runtime, destination, and workload specific. The repository therefore stores scenario baselines and CI artifacts instead of publishing one machine's snapshot as a portable promise.
pnpm bench
pnpm bench:b1
pnpm bench:b7
pnpm profile:ci
pnpm profile:compare
pnpm profile:extensionsThe harness covers message-only, flat fields, child bindings, Error.cause,
buffered output, cold ESM import, wildcard redaction, hooks, formatters, and
transport fan-out. Runs are isolated in child processes, alternate comparison
order, and report throughput, p95/p99, event-loop utilization, RSS, and sampled
allocations. Use --destination file when storage cost matters.
Committed baselines live in benchmark/baselines/. Recalibrate them from
several green runs whenever the Node version, operating system, CPU governor,
hardware, or benchmark destination changes.
Testing
pnpm check
pnpm test
pnpm test:e2e
pnpm test:leak
pnpm test:types
pnpm test:package
pnpm release:gateThe release gate builds the package, checks formatting/lint/source and test
types, runs unit/e2e/leak tests, installs the packed tarball into a consumer
fixture, and verifies package metadata and contents. Marked README examples are
extracted and executed by the e2e suite. Every JavaScript and TypeScript fence
is linted by pnpm check; annotated // {"result":"..."} output is compared
with the bytes emitted by its executable example.
Tests cover output byte compatibility, circular and bounded serialization,
Error.cause, redaction, serializers, extension failures, transport fan-out,
delivery counters, timer/size/severity flushing, console restoration, packed
TypeScript resolution, and immediate-exit output.
Release
CI runs for v* tags and manual dispatches. A release tag must equal the
package.json version (vX.Y.Z), its commit must belong to master, the pnpm
version must be pinned, and pnpm-lock.yaml must match the manifest.
After all correctness and benchmark gates pass, the package job:
- builds
dist/from the checked-out commit; - packs exactly once and validates package identity and a strict file allowlist;
- writes
release-manifest.jsonwith size, git SHA, SHA-256, SHA-512, and npm integrity; - uploads the tarball, manifest, checksum, and publish script as one artifact.
The publish job downloads that exact artifact, verifies SHA256SUMS, recomputes
tarball integrity, and queries npm before publishing. Retrying an already
published identical version succeeds; the same version with different content
fails closed. Manual dispatch runs every gate and creates the artifact without
publishing.
The publish job runs on a GitHub-hosted runner, requests an OIDC identity token,
and makes npm provenance mandatory. publishConfig.provenance and the explicit
--provenance argument also prevent an accidental non-attested publication.
npm requires the source repository to be public before it will issue a
provenance attestation. A release tag therefore fails during source-policy
while this GitHub repository is private. Make the repository public before
creating the first release tag; do not bypass the check or publish the version
manually without provenance.
For a local release rehearsal, first ensure release-artifact/ is empty:
pnpm releaseThis runs every correctness gate and creates the same immutable artifact, but it does not publish: provenance publication is CI-only. The output directory is intentionally fail-closed so stale tarballs cannot be mixed with a new manifest. Never reuse a published version or release tag.
Rollback moves latest to a known-good version and deprecates the bad version:
npm dist-tag add @swarmmachina/swm-logs@<GOOD_VERSION> latest
npm deprecate @swarmmachina/swm-logs@<BAD_VERSION> "Use <GOOD_VERSION>"Self-hosted runner
regression-gate targets the organization runner group swm-ci with the
bench label. The workflow has no pull-request trigger: untrusted PR code must
not run on a shared self-hosted runner.
Keep the benchmark runner isolated and disposable, run it as an unprivileged user, allow only required outbound traffic, and expose no production secrets. Prefer ephemeral registration so every release starts from a clean machine:
./config.sh --url https://github.com/<owner>/<repo> --token <RUNNER_TOKEN> --labels bench --ephemeral
sudo ./svc.sh install <user>
sudo ./svc.sh startKeep the host idle during profiles and use a stable performance governor where the platform supports it.
Runtime design
Loggerowns per-instance bindings and level state.LevelRegistryowns validated immutable level lookup tables.FieldSerializerowns serializers/redaction configuration and is shared by children.Redactorowns the compiled single-path or branch-trie strategy without mutating caller data.OutputDestinationowns one stdout/stderr/fd/writer target.ExtensionPipelineexists only when hooks or a formatter are configured.OutputPipelineowns destination/transport fan-out, lifecycle, and shared delivery counters.BufferedWriterowns the opt-in bounded console queue and timer.ConsoleBridgeowns installation and restoration of global console methods.- Argument parsing and JSON transformations remain pure functions.
There is no hidden worker, retry loop, global singleton, or asynchronous queue in the default path. External resource owners stay explicit at the transport boundary.
Stability
The package is experimental at 0.x. Public APIs and runtime behavior may
change before a stable release. The NDJSON envelope and numeric levels are
intended to stabilize first; benchmark baselines remain runner-specific.
Contributing
Contributions should include tests for behavior changes and benchmark evidence for hot-path changes.
- Fork the repository.
- Create a feature branch.
- Run
pnpm release:gateand the relevant performance profile. - Commit and push the branch.
- Open a pull request.
License
Licensed under the MPL-2.0 License.
Copyright Contributors to SwarmMachina.
See LICENSE for details.
