| name | logging |
| description | Use when wiring @toolcase/logging — tiny isomorphic logger with named loggers (scopes), level filtering (silent/error/warning/info/debug/verbose), and pluggable LogReporter sinks (console default, custom transports for remote/file/etc). |
logging — API Reference
Zero-dependency isomorphic logger. Default export is a pre-configured LoggerFactory with one ConsoleLogReporter.
import logging from '@toolcase/logging'
const log = logging.getLogger('boot')
log.info('starting')
log.error('crashed', err)
Also exports the factory class + reporter primitives for custom setups:
import {
logging,
Logger,
Level,
LoggerFactory,
LogReporter,
ConsoleLogReporter,
JSONLineReporter,
BufferedReporter,
isKnownLevel,
KNOWN_LEVELS,
textFormatter,
jsonFormatter,
logfmtFormatter,
} from '@toolcase/logging'
import type {
LogFormatter,
ClockFn,
JSONLineReporterOptions,
JSONLineWriter,
} from '@toolcase/logging'
import { FileLogReporter } from '@toolcase/logging/node'
Levels
type LoggerLevel = 'silent' | 'error' | 'warning' | 'info' | 'debug' | 'verbose'
Note: the LoggerLevel type is not re-exported from the package root — import { LoggerLevel } from '@toolcase/logging' fails. Only the Level value (the string-key constant map) is exported. To type a level, inline the union above, or derive it: type LoggerLevel = typeof Level[keyof typeof Level]. The level methods accept these strings directly, so an explicit type annotation is rarely needed.
Order (lowest → highest verbosity):
| Order | Level |
|---|
| -1 | silent |
| 0 | error |
| 1 | warning |
| 2 | info |
| 3 | debug |
| 4 | verbose |
A message at level L is dispatched only when factory.level >= L. Default factory level is 'info'. silent blocks everything.
logging.level = 'debug'
logging.level
import { Level } from '@toolcase/logging'
logging.level = Level.WARNING
Level constants: SILENT ERROR WARNING INFO DEBUG VERBOSE.
Logger
Named log channel returned by factory.getLogger(scope). Scopes are deduped — same name returns the same instance.
class Logger {
error(...args: any[]): void
warning(...args: any[]): void
info(...args: any[]): void
debug(...args: any[]): void
verbose(...args: any[]): void
log(level: LoggerLevel, ...args: any[]): void
isEnabled(level: LoggerLevel): boolean
setLevel(level: LoggerLevel | null): void
getLevel(): LoggerLevel | null
withContext(ctx: Record<string, any>): Logger
}
Each call timestamps with this.clock() (defaulting to Date.now()) and forwards (level, scope, time, fields, messages) to every reporter (gated by per-logger override if set, otherwise factory level). The time value is epoch milliseconds; ISO formatting happens inside reporters and formatters, not in the logger itself.
Any arg that is a function is called lazily — the function is invoked only when the level is enabled:
log.debug(() => JSON.stringify(heavySnapshot))
log.verbose(() => computeStats(), 'stats')
const auth = logging.getLogger('auth')
auth.warning('invalid token', { ip })
auth.log('debug', 'request payload', payload)
Per-logger level override (setLevel)
setLevel(level) overrides the factory threshold for that logger only — and can both narrow (drop below-threshold) and widen (allow below-factory). setLevel(null) clears the override and defers back to the factory.
logging.level = 'warning'
const noisy = logging.getLogger('noisy')
noisy.setLevel('verbose')
noisy.debug('shown anyway')
const quiet = logging.getLogger('quiet')
quiet.setLevel('error')
quiet.warning('dropped')
quiet.setLevel(null)
The override is per-Logger instance — scopes do not share it.
Structured context binding (withContext)
withContext(ctx) returns a new child Logger (the parent is unchanged) that prepends ctx as the first message arg on every dispatch. Nested calls merge contexts; later keys win.
const log = logging.getLogger('http')
const req = log.withContext({ requestId: 'r-7', userId: 42 })
req.info('handler.start')
const route = req.withContext({ route: 'GET /orders' })
route.warning('slow', { ms: 820 })
Child loggers inherit the parent's setLevel override at creation time. Common pattern: bind a request-scoped context once at the start of the request, pass that logger down.
async function handle(req: Request) {
const log = logging.getLogger('http').withContext({
requestId: req.headers.get('x-request-id'),
method: req.method,
path: new URL(req.url).pathname
})
log.info('start')
try {
const r = await route(req, log)
log.info('ok', { status: r.status })
return r
} catch (e) {
log.error('fail', e)
throw e
}
}
JSONLineReporter will surface the context as the first entry of messages — pair with a custom reporter that promotes context keys to top-level fields if you want flat structured logs.
LoggerFactory
class LoggerFactory {
constructor(reporters?: LogReporter[], clock?: ClockFn)
level: LoggerLevel
getLogger(scope: string = 'default'): Logger
addReporter(reporter: LogReporter): void
removeReporter(reporter: LogReporter): void
flush(): void
close(): Promise<void>
setLevel(pattern: string, level: LoggerLevel): void
parseEnv(env: Record<string, string | undefined>): void
}
Spawn a separate factory when you want a different reporter set or level threshold without affecting the default singleton:
import { LoggerFactory, ConsoleLogReporter } from '@toolcase/logging'
const audit = new LoggerFactory([new ConsoleLogReporter()])
audit.level = 'verbose'
audit.getLogger('audit').verbose('login', { userId })
Add or remove reporters after construction with addReporter / removeReporter. Call flush() to synchronously drain all reporters, and await close() on shutdown to flush and close every reporter in the factory's list.
LogReporter
Base class. Override log() to ship messages to any sink.
class LogReporter {
log(level: LoggerLevel, scope: string, time: number, fields: Record<string, any>, messages: any[]): void
flush(): void
close(): void | Promise<void>
}
time is epoch milliseconds (Date.now() by default). The default base does nothing — subclass it.
ConsoleLogReporter
Built-in transport bound to the default factory. Routes error → console.error, warning → console.warn, everything else → console.log. Default format: <LEVEL> [<ISO time>] | <scope>: ...messages.
INFO [2026-05-03T12:00:00.000Z] | boot: starting
ERROR [2026-05-03T12:00:01.123Z] | auth: invalid token { ip: '1.2.3.4' }
Constructor options (ConsoleLogReporterOptions):
| Option | Type | Default | Description |
|---|
color | boolean | auto | Force color on/off. Auto-enables on Node TTYs and in browsers; honoured by NO_COLOR. |
timestamp | boolean | true | Include the ISO timestamp in the default prefix. |
prefix | string | (level, scope, time) => string | — | Override the prefix entirely (string or function). |
objects | 'compact' | 'pretty' | 'compact' | 'compact' passes values to console as-is; 'pretty' serializes objects/arrays to indented JSON and errors to a string. |
formatter | LogFormatter | — | When set, bypasses the prefix/objects pipeline; calls formatter(level, scope, time, fields, messages) and writes the returned string as a single colored line. |
new ConsoleLogReporter()
new ConsoleLogReporter({ color: false })
new ConsoleLogReporter({ timestamp: false })
new ConsoleLogReporter({ objects: 'pretty' })
new ConsoleLogReporter({
prefix: (level, scope) => `[${scope}] ${level.toUpperCase()}`
})
Color uses ANSI escape codes on Node TTYs and %c CSS styling in the browser. Setting NO_COLOR in the environment disables auto-detection (explicit { color: true } still overrides it).
JSONLineReporter
Structured JSON-line transport. Emits one JSON object per log entry. Isomorphic — defaults to console.log, but accepts any write(line) sink.
import { LoggerFactory, JSONLineReporter } from '@toolcase/logging'
const factory = new LoggerFactory([
new JSONLineReporter({
write: line => process.stdout.write(line + '\n'),
extra: { service: 'api', region: 'eu' },
})
])
const log = factory.getLogger('auth')
log.info('login ok', { userId: 42 })
Errors are serialized to { name, message, stack }. Circular references fall back to '<unserializable>'.
FileLogReporter (Node only)
Writes log lines to a file. Available from @toolcase/logging/node.
import { LoggerFactory } from '@toolcase/logging'
import { FileLogReporter } from '@toolcase/logging/node'
const reporter = new FileLogReporter('./app.log', {
append: true,
formatter: (level, scope, time, messages) =>
`${level.toUpperCase()} [${time}] | ${scope}: ${messages.join(' ')}`
})
const factory = new LoggerFactory([reporter])
const log = factory.getLogger('app')
log.info('boot complete')
await reporter.close()
Size-based rotation: pass maxBytes and maxFiles to rotate when the active file exceeds the byte threshold. The current file is renamed to .1, older archives shift up, and any archive beyond maxFiles is dropped.
new FileLogReporter('./logs/app.log', {
maxBytes: 10 * 1024 * 1024,
maxFiles: 5,
})
Combine with JSONLineReporter to write JSON lines to disk:
import { LoggerFactory, JSONLineReporter } from '@toolcase/logging'
import { createWriteStream } from 'node:fs'
const file = createWriteStream('./app.jsonl', { flags: 'a' })
const reporter = new JSONLineReporter({ write: line => file.write(line + '\n') })
RingBufferReporter
Keeps the last N log entries in a fixed-capacity ring buffer backed by a circular array. Oldest entries are silently evicted when the buffer is full. Isomorphic — works in both Node.js and the browser with no I/O. Ideal for crash dumps, debug overlays, and "attach recent logs to this error report" patterns.
import { LoggerFactory, RingBufferReporter } from '@toolcase/logging'
const ring = new RingBufferReporter(100)
const factory = new LoggerFactory([ring])
factory.level = 'debug'
const log = factory.getLogger('app')
log.info('boot complete')
log.warning('slow query', { ms: 420 })
log.error('connection lost', err)
const entries = ring.snapshot()
reportError({ recentLogs: ring.snapshot() })
API:
class RingBufferReporter extends LogReporter {
constructor(capacity: number)
readonly size: number
readonly capacity: number
snapshot(): LogEntry[]
clear(): void
}
LogEntry shape (also exported from @toolcase/logging):
type LogEntry = {
level: LoggerLevel
scope: string
time: number
fields: Record<string, any>
messages: any[]
}
Compose with ConsoleLogReporter to log to console and retain the ring for diagnostics:
const ring = new RingBufferReporter(200)
const factory = new LoggerFactory([new ConsoleLogReporter(), ring])
window.onerror = () => sendDiagnostics(ring.snapshot())
BufferedReporter
Batching/debounce wrapper. Buffers entries and flushes them either when maxSize is reached or after flushInterval ms — whichever comes first. Use it to wrap any slow sink (HTTP, database, filesystem) so per-call cost stays cheap.
import { LoggerFactory, BufferedReporter, ConsoleLogReporter } from '@toolcase/logging'
const buffered = new BufferedReporter(new ConsoleLogReporter(), {
maxSize: 100,
flushInterval: 2000
})
const remote = new BufferedReporter(null, {
maxSize: 50,
flushInterval: 1000,
onFlush: entries => {
fetch('/api/logs', {
method: 'POST',
body: JSON.stringify(entries)
}).catch(() => {})
}
})
const factory = new LoggerFactory([remote])
factory.getLogger('app').info('boot complete')
remote.close()
API:
class BufferedReporter extends LogReporter {
constructor(
inner: LogReporter | null,
options?: {
maxSize?: number // default 50
flushInterval?: number // default 1000ms; 0 disables the timer
onFlush?: (entries: LogEntry[]) => void
}
)
flush(): void
close(): void
size(): number
}
Either inner or onFlush must be provided. When both are present, onFlush wins (the inner reporter is bypassed). A throw inside onFlush (or the wrapped inner) propagates out of flush() — but where depends on what triggered the flush: a maxSize-overflow flush runs synchronously from the logger.<level>(...) call, so the throw reaches that call site; a flushInterval timer flush runs from a setTimeout callback, so the throw becomes an unhandled exception (no call-site to catch it). Always wrap your I/O.
Reporters run synchronously in registration order; a throw inside a synchronous reporter bubbles up to the call site of logger.<level>(...). The exception is a reporter that flushes off a timer (e.g. BufferedReporter's flushInterval path) — a throw there fires from a setTimeout callback and surfaces as an unhandled exception, not at the call site. Either way, wrap your I/O. See worked custom-reporter examples below.
HTTPReporter
Isomorphic HTTP transport. Buffers entries internally (via BufferedReporter) and POSTs each batch to a configurable endpoint as a JSON body. Retries on network errors or non-2xx responses using exponential back-off. Works in Node 18+ (native fetch) and modern browsers.
import { LoggerFactory, HTTPReporter } from '@toolcase/logging'
const reporter = new HTTPReporter({
url: 'https://ingest.example.com/logs',
headers: { Authorization: 'Bearer my-token' },
maxSize: 100,
flushInterval: 5000,
retries: 3,
retryMinTimeout: 500,
})
const factory = new LoggerFactory([reporter])
factory.getLogger('api').info('boot', { version: '1.0.0' })
reporter.close()
Each POST body is { "entries": LogEntry[] } where each entry carries:
{
"entries": [
{
"level": "info",
"scope": "api",
"time": 1767225600000,
"fields": {},
"messages": ["boot", { "version": "1.0.0" }]
}
]
}
Inject a custom transport for testing or for environments with a non-standard fetch:
import { HTTPReporter, type HTTPTransport } from '@toolcase/logging'
const transport: HTTPTransport = async (url, body, headers) => {
const res = await myCustomFetch(url, { method: 'POST', body, headers })
return res.statusCode
}
const reporter = new HTTPReporter({ url: '...', transport })
API:
type HTTPTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number>
interface HTTPReporterOptions {
url: string
headers?: Record<string, string>
maxSize?: number
flushInterval?: number
retries?: number
retryMinTimeout?: number
transport?: HTTPTransport
}
class HTTPReporter extends LogReporter {
constructor(options: HTTPReporterOptions)
flush(): void
close(): void
}
OTLPReporter
OpenTelemetry-compatible HTTP transport. Buffers entries internally and POSTs each batch to an OTLP HTTP endpoint as a LogsData JSON body. Bridges level→severityNumber/severityText and maps scope/fields→attributes. Works in Node 18+ (native fetch) and modern browsers.
import { LoggerFactory, OTLPReporter } from '@toolcase/logging'
const reporter = new OTLPReporter({
url: 'https://otel-collector.example.com/v1/logs',
headers: { 'otlp-api-key': 'my-api-key' },
resource: { 'service.name': 'api', 'deployment.environment': 'prod' },
maxSize: 100,
flushInterval: 5000,
retries: 3,
retryMinTimeout: 500,
})
const factory = new LoggerFactory([reporter])
const log = factory.getLogger('api').withContext({ requestId: 'r1' })
log.info('server started', { port: 3000 })
reporter.close()
Level → severity mapping:
LoggerLevel | severityNumber | severityText |
|---|
verbose | 1 | TRACE |
debug | 5 | DEBUG |
info | 9 | INFO |
warning | 13 | WARN |
error | 17 | ERROR |
Scope and fields → attributes: each log record carries a log.scope attribute set to the logger's scope string, plus one attribute per withContext() field. The logger scope also appears as the OTLP InstrumentationScope.name.
The POST body is a LogsData JSON object. Inject a custom transport for testing:
import { OTLPReporter, type OTLPTransport } from '@toolcase/logging'
const transport: OTLPTransport = async (url, body, headers) => {
const res = await myFetch(url, { method: 'POST', body, headers })
return res.status
}
const reporter = new OTLPReporter({ url: '...', transport })
API:
type OTLPTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number>
interface OTLPReporterOptions {
url: string
headers?: Record<string, string>
resource?: Record<string, any>
maxSize?: number
flushInterval?: number
retries?: number
retryMinTimeout?: number
transport?: OTLPTransport
}
class OTLPReporter extends LogReporter {
constructor(options: OTLPReporterOptions)
flush(): void
close(): void
}
Examples per level
Each level method forwards ...args to every reporter. Mix strings, objects, errors freely — the reporter decides formatting.
import logging from '@toolcase/logging'
const log = logging.getLogger('checkout')
log.error('payment rejected', new Error('card_declined'), { userId: 17 })
log.warning('retry budget exhausted', { attempts: 3 })
log.info('order placed', { orderId: 'o_42', total: 99.5 })
log.debug('cart snapshot', cart)
log.verbose('per-line trace', { step: 'reserve_inventory', took: 12 })
log.log(level, ...args) is the dynamic form — useful when the level is data-driven:
const level = response.ok ? 'info' : 'warning'
log.log(level, 'http response', { status: response.status, url })
Cap verbosity globally — anything above factory.level is silently dropped:
logging.level = 'warning'
log.info('skipped')
log.error('shown')
'silent' blocks every level (including error). Use it for tests where logs should be absent.
Examples — Logger scopes
Scopes give you per-subsystem prefixes without juggling instances. Same name returns the same logger.
const a1 = logging.getLogger('auth')
const a2 = logging.getLogger('auth')
console.log(a1 === a2)
Pattern: one logger per file, scope == module name.
import logging from '@toolcase/logging'
const log = logging.getLogger('payments')
export async function charge(card: Card) {
log.debug('charge.start', { card: card.last4 })
try {
const r = await api.charge(card)
log.info('charge.ok', { id: r.id })
return r
} catch (err) {
log.error('charge.fail', err)
throw err
}
}
Hierarchical scopes — naming convention only, no real nesting in the API:
logging.getLogger('http')
logging.getLogger('http.request')
logging.getLogger('http.response')
A reporter can branch on scope (see "Filter by scope" below) to route or filter by subsystem.
Examples — LoggerFactory
Multiple reporters, fan-out
const factory = new LoggerFactory([
new ConsoleLogReporter(),
new RemoteReporter(),
new InMemoryRingBuffer()
])
factory.level = 'info'
No console output, just a buffer
class RingBuffer extends LogReporter {
private events: any[] = []
log(level: any, scope: string, time: number, fields: Record<string, any>, messages: any[]) {
this.events.push({ level, scope, time, fields, messages })
if (this.events.length > 500) this.events.shift()
}
snapshot() { return this.events.slice() }
}
const buffer = new RingBuffer()
const factory = new LoggerFactory([buffer])
factory.getLogger('app').info('hello')
buffer.snapshot()
Examples — LogReporter
For Node file output, prefer the built-in FileLogReporter (or JSONLineReporter over fs.createWriteStream) shown above — extend LogReporter directly only when you need custom routing/filtering/transformation.
Remote reporter (batched)
For network I/O, wrap a sink with BufferedReporter (see API above) — onFlush ships the whole batch:
new BufferedReporter(null, {
maxSize: 50,
flushInterval: 500,
onFlush: entries => {
fetch('/api/logs', { method: 'POST', body: JSON.stringify(entries) }).catch(() => {})
}
})
Filter by scope
class ScopedReporter extends LogReporter {
constructor(private prefix: string, private inner: LogReporter) { super() }
log(level, scope, time, fields, messages) {
if (!scope.startsWith(this.prefix)) return
this.inner.log(level, scope, time, fields, messages)
}
}
new LoggerFactory([new ScopedReporter('http.', new ConsoleLogReporter())])
Browser console with console.group per scope
class GroupedReporter extends LogReporter {
log(level, scope, time, fields, messages) {
const fn = level === 'error' ? console.error
: level === 'warning' ? console.warn : console.log
console.groupCollapsed(`${level.toUpperCase()} ${scope} ${time}`)
fn(...messages)
console.groupEnd()
}
}
Scope-pattern level control
setLevel(pattern, level) overrides the threshold for all scopes matching a glob pattern. The most-specific pattern (fewest wildcards) wins when multiple patterns match; last-registered wins on a tie.
const factory = new LoggerFactory([reporter])
factory.level = 'warning'
factory.setLevel('db:*', 'debug')
factory.setLevel('db:pool', 'info')
factory.getLogger('db:pool').debug('dropped — db:pool exact pattern requires ≥ info')
factory.getLogger('db:pool').info('shown')
factory.getLogger('db:query').debug('shown — matched by db:*')
factory.getLogger('auth').debug('dropped — no pattern, global is warning')
Pattern syntax: * matches any sequence of characters (including : separators).
factory.setLevel('auth*', 'debug')
factory.setLevel('db:pool:*', 'info')
factory.setLevel('*', 'debug')
Throws RangeError for an unknown level string (same as the level setter).
Hierarchical scopes via child()
logger.child(childScope) returns a new logger whose scope is parentScope:childScope. Useful for building nested namespaces without repeating the parent prefix.
const db = factory.getLogger('db')
const pool = db.child('pool')
const worker = pool.child('worker')
pool.debug('shown')
worker.debug('shown')
The child inherits the parent's withContext fields and setLevel override at creation time. It dispatches through the same factory reporters.
const req = factory.getLogger('http').withContext({ requestId: 'r1' })
const handler = req.child('orders')
handler.info('start')
Env-driven configuration (parseEnv)
parseEnv(env) reads LOG_LEVEL and DEBUG from a plain key-value object (pass process.env in Node, import.meta.env in Vite, or your own record).
factory.parseEnv(process.env)
LOG_LEVEL must be one of the six known level tokens; unknown values are silently ignored. DEBUG is comma-or-space-separated and each token becomes a scope pattern at level debug. Unknown LOG_LEVEL tokens are silently skipped (the factory level is unchanged).
import logging from '@toolcase/logging'
logging.parseEnv(process.env)
import logging from '@toolcase/logging'
logging.parseEnv(import.meta.env)
Patterns
Switch verbosity from env (Node):
import logging from '@toolcase/logging'
import { env } from '@toolcase/node'
logging.level = env('LOG_LEVEL', 'info') as any
Bind once, log error+rethrow helper:
const log = logging.getLogger('jobs')
export function loud<T>(name: string, fn: () => Promise<T>): Promise<T> {
log.debug(name + '.start')
return fn().then(
v => { log.info(name + '.ok'); return v },
e => { log.error(name + '.fail', e); throw e }
)
}
await loud('reindex', () => reindexUsers())
Replace default reporter set:
The default singleton ships with ConsoleLogReporter. To use a different transport, instantiate your own LoggerFactory and import that everywhere instead of the default.
Cross-library integration
With @toolcase/base retry
import { retry } from '@toolcase/base'
import logging from '@toolcase/logging'
const log = logging.getLogger('http')
await retry(async () => {
const r = await fetch('/api/data')
if (!r.ok) {
log.warning('retry.attempt.fail', { status: r.status })
throw new Error(`HTTP ${r.status}`)
}
return r.json()
}, { retries: 5, factor: 2, minTimeout: 500 })
With @toolcase/base Broadcast
Broadcast events are silent by default — pipe them into a logger when debugging:
import { Broadcast } from '@toolcase/base'
class Service extends Broadcast {
work() { this.emit('done', { ok: true }) }
}
const log = logging.getLogger('svc')
const svc = new Service()
svc.on('done', payload => log.info('done', payload))
With @toolcase/phaser-plus Engine
Engine.getLogger(scope) returns a Logger — the engine wires its own factory but the public API is identical.
Composable wrapper reporters
Five decorator reporters that each wrap one inner LogReporter and forward the single-method SPI. Compose them freely with FanoutReporter to build per-sink pipelines.
import {
LevelFilterReporter, ScopeFilterReporter,
RedactionReporter, SamplingReporter,
FanoutReporter, MultiReporter,
} from '@toolcase/logging'
LevelFilterReporter
Forwards only entries whose level is at or above minLevel. Lets you attach a stricter threshold to one specific sink without changing the factory's global level.
class LevelFilterReporter extends LogReporter {
constructor(inner: LogReporter, minLevel: LoggerLevel)
}
const factory = new LoggerFactory([
new LevelFilterReporter(new ConsoleLogReporter(), 'error'),
new RingBufferReporter(200),
])
factory.level = 'verbose'
ScopeFilterReporter
Forwards only entries whose scope matches a glob pattern. * matches any sequence of characters, including : separators (same syntax as factory.setLevel(pattern, level)).
class ScopeFilterReporter extends LogReporter {
constructor(inner: LogReporter, pattern: string)
}
const auditSink = new ScopeFilterReporter(new JSONLineReporter({ write }), 'db:*')
const factory = new LoggerFactory([new ConsoleLogReporter(), auditSink])
Pattern examples:
'db:*'
'http*'
'*'
'auth'
RedactionReporter
Walks every entry's fields and each element of messages and replaces values whose key matches keys with the string '[REDACTED]'. Handles nested objects, arrays, and circular references. Error instances are passed through unchanged.
type RedactionKeys = string[] | RegExp
class RedactionReporter extends LogReporter {
constructor(inner: LogReporter, keys: RedactionKeys)
}
const safe = new RedactionReporter(new JSONLineReporter({ write }), [
'password', 'authorization', 'token', 'secret',
])
const safe2 = new RedactionReporter(sink, /password|secret|token/i)
const factory = new LoggerFactory([safe])
factory.getLogger('auth').info('login', { user: 'alice', password: 'hunter2' })
The original messages array is never mutated.
SamplingReporter
Forwards each entry to inner with probability rate (0–1). Useful for high-throughput trace-level logs where you want a statistical sample rather than every entry.
class SamplingReporter extends LogReporter {
constructor(inner: LogReporter, rate: number)
}
const sampled = new SamplingReporter(remoteSink, 0.1)
new SamplingReporter(sink, 1)
new SamplingReporter(sink, 0)
FanoutReporter / MultiReporter
Broadcasts every log entry to a list of inner reporters. Each reporter is called in a try/catch so a throw in one reporter does not skip the others. flush() and close() are forwarded to all inner reporters (errors isolated individually). MultiReporter is an alias.
class FanoutReporter extends LogReporter {
constructor(reporters: LogReporter[])
}
const MultiReporter = FanoutReporter
const fanout = new FanoutReporter([
new LevelFilterReporter(new ConsoleLogReporter(), 'error'),
new ScopeFilterReporter(auditSink, 'audit:*'),
new RedactionReporter(jsonSink, ['password', 'token']),
new SamplingReporter(traceSink, 0.05),
])
const factory = new LoggerFactory([fanout])
factory.level = 'verbose'
Because each wrapper forwards flush() and close(), calling factory.close() drains the entire pipeline correctly.
BeaconReporter (browser only — @toolcase/logging/browser)
Buffers entries in memory and flushes them via navigator.sendBeacon — a fire-and-forget network call that is guaranteed to complete even during page unload. The underlying BufferedReporter installs a pagehide listener automatically. Optionally captures global errors via window.onerror and unhandledrejection.
import { BeaconReporter } from '@toolcase/logging/browser'
import { LoggerFactory } from '@toolcase/logging'
const reporter = new BeaconReporter({
url: 'https://telemetry.example.com/logs',
maxSize: 50,
flushInterval: 0,
captureErrors: true,
errorScope: 'window',
})
const factory = new LoggerFactory([reporter])
factory.level = 'warning'
Flushed payload: JSON.stringify(LogEntry[]) sent as the sendBeacon body.
interface BeaconReporterOptions {
url: string
maxSize?: number
flushInterval?: number
captureErrors?: boolean
errorScope?: string
}
class BeaconReporter extends LogReporter {
constructor(options: BeaconReporterOptions)
log(level, scope, time, fields, messages): void
flush(): void
close(): void
}
Use when: capturing production errors in a browser SPA with zero setup — especially during navigation-away where fetch would be cancelled.
Reuses: BufferedReporter (batching, pagehide listener).
IndexedDBReporter (browser only — @toolcase/logging/browser)
Persists log entries to an IndexedDB object store so they survive page reloads. Writes are serialized and non-blocking. Call drain() on next startup to retrieve and clear the stored entries for upload. Evicts the oldest entries when maxEntries is exceeded.
import { IndexedDBReporter } from '@toolcase/logging/browser'
import { LoggerFactory } from '@toolcase/logging'
const reporter = new IndexedDBReporter({
dbName: '@toolcase/logging',
storeName: 'log-entries',
maxEntries: 1000,
})
const factory = new LoggerFactory([reporter])
factory.level = 'error'
const buffered = await reporter.drain()
await fetch('/logs', { method: 'POST', body: JSON.stringify(buffered) })
interface IndexedDBReporterOptions {
dbName?: string
storeName?: string
maxEntries?: number
}
class IndexedDBReporter extends LogReporter {
constructor(options?: IndexedDBReporterOptions)
log(level, scope, time, fields, messages): void
flush(): Promise<void>
drain(): Promise<LogEntry[]>
close(): Promise<void>
}
Use when: you need crash-resilient log buffering across reloads — e.g., capturing errors before an upload attempt that might itself fail.
Reuses: LogReporter (base class). Pairs naturally with BeaconReporter or a fetch-based uploader called from drain().
MemoryReporter
Non-draining in-memory reporter for unit tests. Retains every entry it receives — entries() and find(level) never drain the store. Use clear() to reset between test cases.
import { LoggerFactory, MemoryReporter } from '@toolcase/logging'
const mem = new MemoryReporter()
const factory = new LoggerFactory([mem])
factory.level = 'verbose'
const log = factory.getLogger('app')
log.info('boot complete')
log.warning('slow query', { ms: 420 })
log.error('connection lost')
log.info('reconnected')
mem.entries()
mem.find('info')
mem.find('error')
mem.clear()
mem.entries()
Pair with a fixed-clock factory for deterministic timestamps:
const mem = new MemoryReporter()
const factory = new LoggerFactory([mem], () => 1_000_000)
factory.getLogger('svc').info('event')
const [entry] = mem.entries()
entry.time
entry.scope
entry.level
entry.fields
entry.messages
API:
class MemoryReporter extends LogReporter {
entries(): LogEntry[]
find(level: LoggerLevel): LogEntry[]
clear(): void
}
Use when: writing unit tests that assert on log output without worrying about draining or flush timing.
Formatters
Three ready-made LogFormatter implementations are exported. A LogFormatter has the signature:
type LogFormatter = (level: LoggerLevel, scope: string, time: number, fields: Record<string, any>, messages: any[]) => string
Pass one to ConsoleLogReporter or JSONLineReporter via the formatter option, or call them directly in a custom reporter.
| Export | Output style |
|---|
textFormatter | LEVEL [ISO-timestamp] | scope: msg1 msg2 … |
jsonFormatter | JSON object: { ...fields, level, scope, time, messages } (default for JSONLineReporter) |
logfmtFormatter | level=info scope=auth ts=ISO msg="login ok" with per-field pairs from fields |
import { textFormatter, jsonFormatter, logfmtFormatter, ConsoleLogReporter, JSONLineReporter } from '@toolcase/logging'
new ConsoleLogReporter({ formatter: textFormatter })
new JSONLineReporter({ formatter: logfmtFormatter, write: line => process.stdout.write(line + '\n') })
const myFmt: LogFormatter = (level, scope, time, fields, messages) =>
`[${scope}] ${level}: ${messages.join(' ')}`
new ConsoleLogReporter({ formatter: myFmt })
Notes
- Package is
sideEffects: false and zero-dependency.
- Importing the default export instantiates one
LoggerFactory + one ConsoleLogReporter — safe in both Node and browser.
- No async / no buffering — every
logger.x() call dispatches synchronously.
- Timestamps are epoch milliseconds from
this.clock() (default Date.now()). ISO formatting happens in reporters and formatters, not in the logger.
@toolcase/logging/node
Node-only exports. Uses node:fs (write streams) and node:async_hooks (AsyncLocalStorage). Import from the /node subpath:
import {
StreamReporter,
FileLogReporter,
defaultStreamFormatter,
AsyncContext,
ContextualReporter,
} from '@toolcase/logging/node'
import type {
StreamLogFormatter,
StreamReporterOptions,
FileLogFormatter,
FileLogReporterOptions,
} from '@toolcase/logging/node'
StreamReporter
Base class for writable-stream transports. Writes one formatted line per entry. Supports optional size-based rotation via maxBytes — when the accumulated byte count of the current stream exceeds the threshold, new lines are queued while openRotatedStream() runs asynchronously, then flushed to the new stream.
import { createWriteStream } from 'node:fs'
import { StreamReporter, defaultStreamFormatter } from '@toolcase/logging/node'
import { LoggerFactory } from '@toolcase/logging'
const reporter = new StreamReporter(
createWriteStream('./app.log', { flags: 'a' }),
{
formatter: defaultStreamFormatter,
maxBytes: 5 * 1024 * 1024,
onError: err => console.error('stream error', err),
}
)
const factory = new LoggerFactory([reporter])
factory.getLogger('boot').info('started')
await reporter.close()
API:
interface StreamReporterOptions {
formatter?: LogFormatter
maxBytes?: number
onError?: (err: Error) => void
}
type StreamLogFormatter = LogFormatter
class StreamReporter extends LogReporter {
constructor(stream: Writable, options?: StreamReporterOptions)
log(level, scope, time, fields, messages): void
close(): Promise<void>
}
defaultStreamFormatter is the same as textFormatter from @toolcase/logging — produces LEVEL [ISO-timestamp] | scope: …messages.
Subclass StreamReporter and override the protected openRotatedStream(): Promise<void> hook to swap in a new destination when maxBytes is exceeded. FileLogReporter uses this hook to rename archive files and open a fresh createWriteStream.
FileLogReporter
File-backed reporter that extends StreamReporter. Opens filePath for writing and rotates when the file grows past maxBytes. During rotation the current file is renamed to filePath.1, older archives shift up (filePath.1 → filePath.2, …), and any archive numbered above maxFiles is deleted.
import { FileLogReporter } from '@toolcase/logging/node'
import { LoggerFactory } from '@toolcase/logging'
const reporter = new FileLogReporter('./logs/app.log', {
append: true,
maxBytes: 10 * 1024 * 1024,
maxFiles: 5,
formatter: defaultStreamFormatter,
onError: err => console.error(err),
})
const factory = new LoggerFactory([reporter])
factory.getLogger('app').info('boot complete')
await reporter.close()
API:
interface FileLogReporterOptions extends StreamReporterOptions {
append?: boolean
maxFiles?: number
}
type FileLogFormatter = StreamLogFormatter
class FileLogReporter extends StreamReporter {
constructor(filePath: string, options?: FileLogReporterOptions)
}
Rotation sequence: end current stream → delete filePath.maxFiles → rename filePath.N → filePath.N+1 (from highest down) → rename filePath → filePath.1 → open new filePath in append mode.
AsyncContext
Thin wrapper around Node.js AsyncLocalStorage. Stores a Record<string, any> that is visible throughout an async_hooks continuation. Each run() call merges new fields on top of the parent store — deeper calls extend, not replace.
import { AsyncContext } from '@toolcase/logging/node'
const ctx = new AsyncContext()
ctx.run({ requestId: 'r-42', userId: 7 }, async () => {
ctx.getFields()
ctx.run({ route: 'POST /orders' }, async () => {
ctx.getFields()
})
})
ctx.getFields()
API:
class AsyncContext {
constructor()
run<T>(fields: Record<string, any>, fn: () => T): T
getFields(): Record<string, any>
}
ContextualReporter
Reporter decorator that reads the current AsyncContext fields on every log call and merges them into fields before forwarding to an inner reporter. Lets you inject per-request or per-task context into log entries without threading a logger through every call site.
import { AsyncContext, ContextualReporter } from '@toolcase/logging/node'
import { LoggerFactory, ConsoleLogReporter } from '@toolcase/logging'
const ctx = new AsyncContext()
const factory = new LoggerFactory([
new ContextualReporter(new ConsoleLogReporter(), ctx),
])
factory.level = 'debug'
const log = factory.getLogger('http')
async function handleRequest(req: Request) {
await ctx.run({ requestId: req.headers.get('x-request-id'), path: new URL(req.url).pathname }, async () => {
log.info('start')
await processRequest(req, log)
log.info('done')
})
}
API:
class ContextualReporter extends LogReporter {
constructor(inner: LogReporter, context: AsyncContext)
log(level, scope, time, fields, messages): void
flush(): void
close(): void | Promise<void>
}
ContextualReporter does not buffer — it calls inner.log() synchronously. flush() and close() are forwarded to inner.