Pular para o conteúdo
Português (BR)

Multi-sink logging

Este conteúdo não está disponível em sua língua ainda.

The default logger writes to exactly one place. MultiSinkLogger fans every record out to several destinations at once, each with its own minimum level:

import { ActorSystem, ActorSystemOptions, LogLevel } from 'actor-ts';
import { ConsoleSink } from 'actor-ts/logging';
const consoleSink = new ConsoleSink();
const systemOptions = ActorSystemOptions.create().withLogSinks([consoleSink]);
const system = ActorSystem.create('my-app', systemOptions);

withLogSinks is the shorthand: the system wraps the list in a MultiSinkLogger, hands the sinks its scheduler, and flushes them when it terminates. Everything else stays as it was — this.log inside an actor, the levels, the MDC.

You can — Logger is a documented extension point, and a hand-written implementation that writes to two places works. What it does not get you is the part that is easy to get wrong:

  • the record is built once, not once per destination;
  • a broken destination stays broken alone — it cannot throw into your code, and it cannot take the other destinations down with it;
  • delivery is bounded — a slow endpoint drops records instead of growing a queue until the process dies;
  • shutdown flushes what is still buffered, within a deadline.

Every sink declares a minLevel, and the pipeline only hands it records at or above it:

import { LogLevel } from 'actor-ts';
import { ConsoleSink } from 'actor-ts/logging';
const humanConsole = new ConsoleSink({ minLevel: LogLevel.Info });
const auditConsole = new ConsoleSink({ minLevel: LogLevel.Error, format: 'json' });
const systemOptions = ActorSystemOptions.create().withLogSinks([humanConsole, auditConsole]);

There are two gates, and they compose in one direction only. The system level (actor-ts.logger.level, or withLogLevel) decides what is produced at all; a sink’s minLevel narrows it further:

actor-ts.logger.level = "info" ← nothing below info exists
└── console sink min-level = "info" → gets info and above
└── file sink min-level = "debug" → still gets info and above

A sink asking for debug while the system level is info receives nothing. Lower the system level first, then narrow per sink — that ordering is what keeps a suppressed log call as cheap as a comparison.

ConsoleSink renders either shape:

const humanReadable = new ConsoleSink({ format: 'text' }); // the default
const machineReadable = new ConsoleSink({ format: 'json' }); // one NDJSON object per line

text is what ConsoleLogger has always written, down to the byte, and positional arguments go to console.* untouched — an Error still renders with its stack, an object still opens as an inspectable preview. json is what JsonLogger writes, down to the key order, so whatever parses your logs today keeps working.

By default text goes through console.debug/log/warn/error (which is what gives you colouring and level routing in a terminal or devtools) and json goes to stdout as a single stream. Force one with stream:

const consoleSink = new ConsoleSink({ format: 'json', stream: 'stderr' });

The pipeline takes one transform hook, applied once before fan-out. It rewrites a record, or returns null to drop it:

import { MultiSinkLogger, MultiSinkLoggerOptions } from 'actor-ts/logging';
const loggerOptions = MultiSinkLoggerOptions.create()
.withSinks([consoleSink])
.withTransform((record) => ({
...record,
message: record.message.replace(/Bearer [\w.-]+/g, 'Bearer [redacted]'),
}));
const logger = new MultiSinkLogger(loggerOptions);

Running before fan-out is the point: every destination sees the same redacted record, so there is no question of which one got the raw value. Keep it cheap and total — it runs on the caller’s stack for every record that passes the level gate. A transform that throws is reported and the record is kept unchanged, because silently deleting somebody’s log stream is the worse failure.

Sinks can come from configuration instead of code:

actor-ts {
logger {
level = "debug"
sinks {
console {
enabled = true
min-level = "info"
format = "json"
}
}
}
}

Every sink ships disabled; enabling at least one replaces the default single ConsoleLogger with a MultiSinkLogger over the enabled set. With nothing enabled, a system behaves exactly as it did before this existed.

Code and config do not merge. Passing withLogger or withLogSinks replaces the whole sinks block rather than adding to it. A destination is described in one place or the other, never half in each — and options are validated in the constructor that owns them, which is only possible if that constructor sees all of them. The precedence, highest first:

  1. withLogger(...) — your own Logger, used as-is;
  2. withLogSinks([...]) — wrapped in a MultiSinkLogger;
  3. actor-ts.logger.sinks.* — the enabled blocks;
  4. the single ConsoleLogger default.

A sink that needs a live object — an SDK instance, a TLS key — has no HOCON block at all and is configured in code by necessity. Each such sink says so on its own page.

system.terminate() flushes and closes the sinks before whenTerminated() resolves, bounded by actor-ts.logger.close-timeout (3 s by default):

actor-ts.logger.close-timeout = 10s

The flush runs after every actor’s postStop, so a parting message from a stopping actor is still in the batch being drained. It also covers both shutdown paths — a direct terminate() and a coordinated shutdown, whose last phase calls terminate() anyway.

A destination that hangs cannot hold the process: when the budget expires the shutdown continues, and a line on the console says what was dropped. Records logged after the sinks close fall back to a plain console line rather than disappearing — shutdown is exactly when a last message matters.

A sink that writes to a file descriptor or a network endpoint does not write per record — it queues, batches, and ships on a timer. BatchingSink is the base class every such sink extends, so the settings mean the same thing everywhere:

actor-ts.logger.sinks.<name>.delivery {
max-batch-size = 100 # most records in one write
flush-interval = 2s # how often the queue is drained
queue-capacity = 10000 # most records that may wait in memory
overflow = "drop-new" # drop-new | drop-head
max-retries = 5 # attempts after the first failure; 0 disables
min-backoff = 1s # first retry delay, doubling per attempt
max-backoff = 30s # ceiling for that delay
random-factor = 0.2 # ±20 % jitter on each delay
}

Or in code, as a nested delivery object on the sink’s options.

The queue is bounded, and that is the point. When it is full a record is dropped — the newest by default, the oldest with drop-head — the count is kept on the sink’s droppedCount, and the loss is reported on the console at most once a minute. An unbounded buffer does not save the records; it converts “some logs were lost” into “the process died”, and hides the problem until it is fatal.

Batches are flushed on the interval, and immediately when max-batch-size records are waiting — otherwise a burst would pay a whole flush interval of latency after the first record.

Retries distinguish worth-retrying from not. A sink signals which by throwing SinkDeliveryError:

import { SinkDeliveryError } from 'actor-ts/logging';
if (response.status === 429 || response.status >= 500) {
const retryAfter = response.headers.get('retry-after');
throw new SinkDeliveryError(`HTTP ${response.status}`, true,
retryAfter ? Number(retryAfter) * 1000 : undefined);
}
if (!response.ok) throw new SinkDeliveryError(`HTTP ${response.status}`, false);

A retryable failure backs off exponentially with jitter, honouring a server-supplied delay when there is one. A non-retryable one — a 401 from a wrong API key — is dropped at once, because five retries with backoff only delay the moment somebody notices. An error that is not a SinkDeliveryError counts as retryable: that is what a socket reset or a failed fetch looks like, and those do pass on their own.

On close, the queue is drained with retries switched off. Whoever is closing already holds a deadline (actor-ts.logger.close-timeout), so a shutdown gets one attempt per batch rather than a backoff schedule that would outlive the process.

A sink is a small object. name, minLevel and write are all that is required:

import type { LogRecord, LogSink } from 'actor-ts/logging';
import { LogLevel } from 'actor-ts';
import { formatJsonLine } from 'actor-ts/logging';
class WebhookSink implements LogSink {
readonly name = 'webhook';
readonly minLevel = LogLevel.Error;
write(record: LogRecord): void {
// Fire-and-forget: `write` must not block the caller...
void fetch('https://example.com/hook', { method: 'POST', body: formatJsonLine(record) })
// ...and must never throw into it.
.catch(() => {});
}
}

Three rules make a sink safe to put underneath an application:

  1. write never throws. The pipeline catches anyway, but a sink that leans on that loses the rest of its batch.
  2. write never blocks. Hand the record to a queue and return. For anything that batches or retries, extend BatchingSink rather than building that machinery again.
  3. A sink never logs through the framework logger. It is the framework logger; reporting a failure that way feeds the failure back into the thing that failed. Use SinkReporter, which rate-limits to the console.

The optional members are attach(context) (you get the system’s scheduler and name), flush() and close().