Sinks
Every built-in sink and wrapper, batching, sub-loggers, flush and close.
A sink is where events go. The interface is four members, one required:
interface Sink {
emit(event: LogEvent): void; // synchronous; never throw for ordinary failures
flush?(): Promise<void> | void; // send what is buffered
close?(): Promise<void> | void; // flush, then release resources
restrictedToMinimumLevel?: LogLevel | LevelSwitch;
audit?: boolean; // failures propagate to the caller
name?: string; // for selfLog messages
}A logger dispatches to every sink in order. A sink that throws is reported to selfLog (or onSinkError) and the others still run — unless it is an audit sink, whose error is yours.
Console#
import { consoleSink } from '@unhingged/logit';
consoleSink(); // pretty on a terminal, JSON on a pipe
consoleSink({ format: 'json' });
consoleSink({ format: 'clef' });
consoleSink({ format: 'text', outputTemplate: '{Timestamp:HH:mm:ss} [{Level:u3}] {Message:lj}{NewLine}{Exception}' });
consoleSink({ stderrFrom: 'error' }); // error and fatal to stderr| Option | Default | Note |
|---|---|---|
format | 'pretty' when colours are on or stdout is a TTY, else 'json' | 'pretty', 'json', 'clef', 'text', or a formatter function |
outputTemplate | the Serilog default | with format: 'text' |
colors | detected: TTY and not NO_COLOR; FORCE_COLOR forces | |
utc | false | timestamps in the pretty / text formats |
stderrFrom | — | events at or above this level go to stderr (Serilog's standardErrorFromLevel) |
restrictedToMinimumLevel, audit | — | as on every sink |
Off Node — where there is no process.stdout — the sink writes through console.*. In a real browser (a document exists) the pretty format calls console.info / warn / error with the rendered message, the extra properties as an object and the error as an error, so the devtools keep them expandable. In the Next.js edge runtime, a worker or any other runtime without a document, the default is JSON lines through console.log, which is what those platforms collect. A named or custom format logs the formatted line in either case.
Stream#
Anything with write(string): a file stream, a socket, a child process' stdin.
import { streamSink } from '@unhingged/logit';
streamSink(process.stderr, { format: 'json' });Same options as the console sink minus stderrFrom. If write() returns false, flush() waits for 'drain'.
File — @unhingged/logit/node#
import { fileSink } from '@unhingged/logit/node';
fileSink({ path: 'logs/app.log' });
fileSink({ path: 'logs/app.log', rolling: 'day', retainedFileCount: 14 });
fileSink({ path: 'logs/app.log', maxBytes: 50 * 1024 * 1024, retainedFileCount: 10 });
fileSink({ path: 'logs/app.log', format: 'text', outputTemplate: '{Timestamp:o} [{Level:u3}] {Message:lj}{NewLine}{Exception}' });| Option | Default | Note |
|---|---|---|
path | required | the directory is created |
format / outputTemplate / utc | 'json' | as above; 'pretty' is allowed but pointless in a file |
rolling | 'none' | 'minute', 'hour', 'day', 'month' — the period goes before the extension: app-20261008.log, app-2026100812.log |
maxBytes | unlimited | when the current file would exceed it, a new one starts: app_001.log, app-20261008_002.log |
retainedFileCount | unlimited | older files for this path are deleted on roll (by modification time) |
mkdir | true |
Writes are synchronous (fs.writeSync): nothing is lost when the process dies, at the cost of one syscall per event. That is fine for the usual thousands of events a second; for more, or for a disk you do not trust to be fast, put the file behind a sub-logger with a filter, or wait for the buffered variant on the roadmap. close() closes the descriptor.
Memory and callback#
import { memorySink, callbackSink } from '@unhingged/logit';
const mem = memorySink({ capacity: 1000 }); // keeps the last 1000
mem.events; // LogEvent[]
mem.messages(); // rendered messages
mem.clear();
callbackSink((event) => metrics.count(event.level));memorySink is the testing tool, and the "last N events" page every ops team wants eventually.
HTTP#
Batched POSTs to any endpoint:
import { httpSink } from '@unhingged/logit';
httpSink({
url: 'https://logs.example.com/ingest',
headers: { authorization: `Bearer ${token}` }, // or () => ({ … }) for rotating tokens
body: 'ndjson', // 'ndjson' | 'array' | 'clef' | (events) => string
batchSize: 100,
flushInterval: 2000,
});| Option | Default | Note |
|---|---|---|
url | required | |
headers | — | an object or a function returning one |
body | 'ndjson' | one JSON event per line (Loki, Splunk HEC, Datadog, most collectors); 'array' for a JSON array; 'clef' for Seq; a function for anything |
formatter | the JSON formatter (CLEF for 'clef') | formats each event in ndjson / array mode — e.g. jsonFormatter({ keys: { timestamp: 'time' } }) |
contentType | by body: application/x-ndjson, application/json, application/vnd.serilog.clef | |
timeout | 10 000 ms | per request |
fetch | globalThis.fetch | inject for tests |
batchSize | 100 | send when this many are queued |
flushInterval | 2000 ms | send at least this often while anything is queued |
maxQueue | 10 000 | events held while the network is down; beyond it the oldest are dropped, with a selfLog note |
retries | 3 | attempts per batch |
retryDelay | 500 ms | doubled per attempt |
onError | — | called with the error and the batch when a batch is given up on |
The request is sent with keepalive: true; in a browser the queue is flushed on pagehide, so the last events of a session leave. The batching behaviour is shared with the Seq and OTLP sinks; the timer is unref'd, so it never keeps a process alive — which is why you close before exiting.
Seq#
import { seqSink } from '@unhingged/logit';
seqSink({ serverUrl: 'http://localhost:5341', apiKey: process.env.SEQ_API_KEY });CLEF over HTTP to /ingest/clef with the X-Seq-ApiKey header; the batching options above apply. See Seq.
OpenTelemetry — @unhingged/logit/otel#
import { otlpHttpSink, otelBridgeSink } from '@unhingged/logit/otel';
otlpHttpSink({ url: 'http://localhost:4318/v1/logs', resource: { 'service.name': 'shop' } });
otelBridgeSink(logs.getLoggerProvider());See OpenTelemetry.
Wrappers#
import { restricted, conditional, audit, mapSink, Matching } from '@unhingged/logit';
restricted(sink, 'warn'); // only warn and above
conditional(Matching.fromSource('billing'), billingSink); // only matching events
audit(ledgerSink); // its failure is the caller's
mapSink((e) => e.properties.TenantId as string, (tenant) => fileSink({ path: `logs/${tenant}.log` }), { maxSinks: 50 });conditionalis Serilog'sWriteTo.Conditional; any predicate works, theMatchinghelpers are the usual ones.auditis Serilog'sAuditTo: a sink whose failure must not be swallowed — an audit ledger, a compliance store. The logging call throws.auditSinks: [...]in the options is the same thing.mapSinkis Serilog.Sinks.Map: one sink per key, created on first sight, the least recently used closed beyondmaxSinks(default 100).keyOfreturningundefinedskips the event.
Sub-loggers#
A Logger is a Sink. Put one in another's sinks and events are re-dispatched through its own minimum level, overrides, bound properties, enrichers, filters and sinks — Serilog's WriteTo.Logger:
const errorsToSeq = createLogger({
minimumLevel: 'error',
filters: [byExcluding(Matching.fromSource('health'))],
sinks: [seqSink({ serverUrl: '…' })],
});
configure({ sinks: [consoleSink(), errorsToSeq] });With the fluent builder: .writeTo.logger((lc) => lc.minimumLevel.error().writeTo.seq({ … })). Destructuring options of the inner logger do not apply — it receives events already captured (Serilog's rule too).
Batching, flush and close#
Network sinks queue and send in batches; everything else writes as it goes. logger.flush() asks every sink to send what it holds and waits; logger.close() flushes and then closes each sink (descriptors, timers). Both tolerate a failing sink — reported to selfLog — except an audit sink, whose failure is thrown.
await log.flush(); // before a serverless function returns
await closeAndFlush(); // the global logger, at exit
closeOnExit(log); // Node: do it on beforeExit / SIGINT / SIGTERM, log crashes firstTo build a batching sink of your own, batchingSink(send, options) is exported: give it send(events): Promise<void> and it does the queue, the timer, the retries and the bounds.
Writing a sink#
import type { Sink } from '@unhingged/logit';
export function metricsSink(): Sink {
return {
name: 'metrics',
emit(event) {
counters.increment(`log.${event.level}`);
if (event.error) counters.increment(`error.${event.error.name}`);
},
};
}Read event.properties (captured, safe), event.renderMessage({ literal: true }) for text, event.messageTemplate, event.eventId, event.errorProperties for the error as data. Format with any formatter.