Logging
Jeder Actor hat einen Logger unter this.log, automatisch an den
Pfad des Actors gebunden:
import { Actor } from 'actor-ts';
class Worker extends Actor<...> { override onReceive(message): void { this.log.info('handling message'); // → [2025-05-13T11:42:01.123Z] INFO actor-ts://my-app/user/worker - handling message }}Vier Methoden auf jedem Logger — debug, info, warn, error:
interface Logger { readonly level: LogLevel; debug(message: string, ...args: unknown[]): void; info(message: string, ...args: unknown[]): void; warn(message: string, ...args: unknown[]): void; error(message: string, ...args: unknown[]): void; withSource(source: string): Logger; withFields(fields: LogContextData): Logger;}Aufrufe unter dem konfigurierten level emittieren nichts —
this.log.debug(...) auf einem System, das für info konfiguriert
ist, produziert keine Ausgabe. Die Argumente werden aber trotzdem
zuerst evaluiert (es sind normale Funktionsargumente), also sichere
teure Payloads mit einem manuellen Level-Check ab (siehe unten).
Level konfigurieren
Abschnitt betitelt „Level konfigurieren“Systemweit über die Settings:
import { ActorSystem, ActorSystemOptions, LogLevel } from 'actor-ts';
const actorSystemOptions = ActorSystemOptions.create().withLogLevel(LogLevel.Debug);const system = ActorSystem.create('my-app', actorSystemOptions);Oder über die Env- / Config-Datei (actor-ts.logger.level = "debug" in
application.conf). Fünf Level: debug / info / warn /
error / off. Default ist info.
Einen Actor benennen — displayName()
Abschnitt betitelt „Einen Actor benennen — displayName()“Ein Pfad ist eine Adresse, kein Name. Unter Sharding wächst er zu etwas, das niemand zweimal liest:
[2026-08-04T16:50:13.356Z] INFO actor-ts://shop/system/cluster/sharding/region-user/shard-13/entity-da76e1eb-a99a-4852-8e14-d5750c20343b - recovery completeÜberschreib displayName(), und der Actor sagt einmal, wer er ist,
statt dass du es in jeder Nachricht wiederholst:
class UserEntity extends PersistentActor<UserCommand, UserEvent, UserState> { override displayName(): string { return `User(${this.entityId})`; }}[2026-08-04T16:50:13.450Z] INFO actor-ts://shop/.../entity-da76e1eb-... - User(test-user-590) - recovery completeDer Name kommt dazu, er ersetzt nichts: source bleibt der Pfad,
also verschiebt sich nichts, worüber du korrelierst. Der Default ist
der Pfad, und ein Name, der dem Pfad entspricht, fällt weg statt
doppelt gedruckt zu werden — ein Actor ohne Override loggt exakt wie
bisher.
Drei Wege, ihn zu setzen, in Precedence-Reihenfolge:
| Wo | Wann du danach greifst |
|---|---|
context.setDisplayName(name) | Der Name steht erst zur Laufzeit fest — nach der Recovery, nach der ersten Nachricht. Außerdem der Weg für Behaviors-Actors, die sich alle eine TypedActor-Klasse teilen und keine eigene Subklasse zum Überschreiben haben. |
ActorOptions.withDisplayName(name) | Die Spawn-Stelle kennt den Namen und der Actor nicht — eine Sharding-Entity, ein Singleton, alles, was das Framework selbst konstruiert. |
displayName() | Der Normalfall: der Actor weiß, was er ist. |
import { ActorOptions } from 'actor-ts';
const workerOptions = ActorOptions.create().withDisplayName('ingest-worker');
system.spawn(IngestWorker, 'ingest', workerOptions);Der Name ist ein Label für Menschen, nie eine Identität.
Metrik-Labels, Tracing-Attribute, Dead Letters, ActorRef.toString()
und jeder Cluster-Wire-Identifier benutzen weiter den Pfad — ein
Display-Name darf also mehrdeutig sein, von mehreren Actors geteilt
werden oder sich im Betrieb ändern. Die
DevTools zeigen ihn als
Zeilen-Label, den Pfad weiterhin im Tooltip.
Strukturierte JSON-Logs — eingebauter JsonLogger
Abschnitt betitelt „Strukturierte JSON-Logs — eingebauter JsonLogger“Für Log-Aggregator-Pipelines (Loki, ELK, Datadog, CloudWatch,
Splunk etc.) willst du ein JSON-Objekt pro Record, nicht den
menschenlesbaren Text, den der Default-ConsoleLogger ausgibt.
JsonLogger ist die mitgelieferte Implementierung:
import { ActorSystem, ActorSystemOptions, JsonLogger } from 'actor-ts';
const actorSystemOptions = ActorSystemOptions.create().withLogger(new JsonLogger());const system = ActorSystem.create('my-app', actorSystemOptions);Output (ein \n-terminiertes JSON-Objekt pro Record):
{"ts":"2026-05-14T12:34:56.789Z","level":"info", "source":"actor-ts://my-app/user/order", "msg":"placing order", "correlationId":"abc-123","userId":"user-42", "args":[{"items":42}]}Jeder Record trägt ts (ISO-8601), level, msg und die
gemergten statischen + dynamischen MDC-Felder (statisch aus
withFields, dynamisch aus LogContext.run, dynamisch gewinnt
bei Key-Kollision). Zusätzliche positionale ...args aus
log.info(message, extra1, extra2) landen unter einem args-Array
— Log-Aggregators indexieren verschachtelte Keys automatisch.
Ein Actor, der sich benannt hat, ergänzt einen displayName-Key neben
source, wo er eigenständig filterbar ist — der ConsoleLogger faltet
beide in eine Zeile, strukturierte Ausgabe hält sie getrennt:
{"ts":"2026-08-04T16:50:13.450Z","level":"info", "source":"actor-ts://shop/.../entity-da76e1eb-...", "displayName":"User(test-user-590)", "msg":"recovery complete"}Error-Instanzen werden als { name, message, stack }
serialisiert statt als Default-"{}" (Errors enumerierbare
Surface ist leer). Zyklische Referenzen, BigInt und Funktionen
werden sanitiert, damit ein Log-Call nie wirft.
Der Konstruktor von JsonLogger nimmt optional einen Sink —
default schreibt er nach process.stdout, der richtigen Pipe für
den Docker-Logging-Driver, den Kubernetes-Log-Scraper, vector,
fluent-bit und jq:
new JsonLogger(LogLevel.Info, '', {}, { write: (line) => process.stderr.write(line), // umleitung nach stderr});OTLP-Logs-Pipeline — otelLogger
Abschnitt betitelt „OTLP-Logs-Pipeline — otelLogger“Für einen OpenTelemetry-Collector, der über
@opentelemetry/sdk-logs + OTLPLogExporter angebunden ist,
brückst du über die OTel-Logs-API statt stdout-JSON:
import * as logsApi from '@opentelemetry/api-logs';import { ActorSystem, ActorSystemOptions, otelLogger } from 'actor-ts';
// (SDK-Setup: LoggerProvider + OTLP-Exporter registrieren — außerhalb des Scope.)
const actorSystemOptions = ActorSystemOptions.create().withLogger(otelLogger({ api: logsApi }));const system = ActorSystem.create('my-app', actorSystemOptions);@opentelemetry/api-logs ist eine optionale Peer-Dep — das
Framework importiert sie nie, du bringst deinen vorhandenen
Namespace-Import mit. Jedes this.log.info(...) in einem Actor
landet als OTel-LogRecord mit Severity gemappt auf OTels
Standard-Severity-Number-Bereich, dem Actor-Pfad auf source,
den gemergten MDC-Feldern auf attributes und der traceId/
spanId des aktiven Spans automatisch verlinkt, wenn Tracing
im selben Prozess aktiv ist.
Eigener Logger
Abschnitt betitelt „Eigener Logger“Das Logger-Interface ist klein genug, um es direkt zu
implementieren, wenn keiner der eingebauten passt — z. B. willst
du ein spezifisches Binär-Wireformat, willst per Fanout auf
mehrere Sinks routen, oder eine bestehende Log-Library wrappen.
Gleiche Shape wie die eingebauten:
import { type Logger, LogLevel, type LogContextData } from 'actor-ts';
class MyLogger implements Logger { level = LogLevel.Info; debug(message: string, ...args: unknown[]): void { /* ... */ } info(message: string, ...args: unknown[]): void { /* ... */ } warn(message: string, ...args: unknown[]): void { /* ... */ } error(message: string, ...args: unknown[]): void { /* ... */ } withSource(source: string): Logger { /* gib eine gebundene Kopie zurück */ return this; } withFields(fields: LogContextData): Logger { /* gib eine gebundene Kopie zurück */ return this; }}Das Framework ruft withSource einmal pro Actor auf, um den Pfad
des Actors zu binden; das musst du nicht selbst tun.
Strukturierte Felder via withFields
Abschnitt betitelt „Strukturierte Felder via withFields“Statische Felder — derselbe Wert auf jedem Record, der von diesem Logger emittiert wird:
class ShardCoordinator extends Actor<...> { private log!: Logger;
override preStart(): void { this.log = this.context.log.withFields({ component: 'shard-coordinator', shardId: this.shardId, }); }
override onReceive(message): void { this.log.info('rebalance start'); // → ... shard-coordinator - rebalance start {component=shard-coordinator, shardId=12} }}withFields gibt einen neuen Logger zurück, der die Felder auf
jedem Emit eingestempelt hat. Nützlich für Component-Level-Tagging,
das sich über Nachrichten nicht ändert. Binde ihn in preStart
statt in einem Feld-Initializer — this.context ist erst verfügbar,
wenn der Actor attached ist, ein Feld-Initializer würde also bei der
Konstruktion werfen.
Dynamischer Kontext — LogContext (MDC)
Abschnitt betitelt „Dynamischer Kontext — LogContext (MDC)“Für Felder, die pro Request statt pro Actor variieren — eine
Correlation-ID, eine Request-ID, eine User-ID — verwende die
LogContext-MDC. Setze sie am Einstiegspunkt; jeder Log-Aufruf
darin liest sie automatisch:
import { LogContext, randomUuid } from 'actor-ts';
// HTTP-Request-Handler — wickelt die Actor-Arbeit in einen Kontext-Scope.app.post('/orders', async (req, res) => { const correlationId = req.headers['x-correlation-id'] ?? randomUuid();
await LogContext.run({ correlationId, userId: req.user.id }, async () => { const result = await orderActor.ask({ kind: 'place', ... }); res.json(result); });});
// Innerhalb jedes Actors, der via `tell` / `ask` von dort erreicht wird:class OrderActor extends Actor<...> { override onReceive(message): void { this.log.info('placing order'); // → ... order-actor - placing order {correlationId=abc-123, userId=user-42} paymentActor.tell({ kind: 'charge', ... }); }}LogContext ist mit AsyncLocalStorage unterlegt — der Kontext
propagiert über awaits, tells und Cluster-Hops hinweg. Die
Operationen:
| Methode | Was sie tut |
|---|---|
LogContext.run(context, callback) | Führt callback mit context als aktuellem Kontext aus. |
LogContext.with(extra, callback) | Führt callback mit { ...current, ...extra } als Kontext aus. |
LogContext.get() | Liest den aktuellen Kontext (leeres Objekt, wenn keiner aktiv). |
LogContext.snapshot() | Kopiert den aktuellen Kontext in ein frisches, veränderbares Objekt. |
LogContext.runFresh(callback) | Führt callback mit geleertem Kontext aus und ignoriert den umgebenden. |
LogContext.runEach(entries, callback) | Führt callback je Eintrag aus, jeweils unter dessen mitgeschnappschusstem Kontext. |
get() liefert den lebenden Readonly-Kontext — dieselbe Referenz für
den ganzen Scope. Halte sie deshalb nie über den Scope hinaus fest
und gib sie nichts, was sie mutieren könnte. An einer Grenze ist
snapshot() das Mittel der Wahl: es liefert bei jedem Aufruf eine
frische Kopie, die du behalten, verändern oder an einen Serialisierer
geben kannst.
Statische Felder (withFields) und dynamische MDC mergen zur
Emit-Zeit; dynamisch gewinnt bei Schlüssel-Kollision
(innermost-scope-wins-Intuition).
Wie MDC über tell propagiert
Abschnitt betitelt „Wie MDC über tell propagiert“Wenn du ref.tell(message) innerhalb eines LogContext.run-Scopes
aufrufst, snapshotet die Runtime den aktuellen Kontext auf das
Envelope. Das onReceive des empfangenden Actors läuft unter
einem frischen LogContext.run dieses Snapshots. Das bedeutet:
- Eine einzige
correlationIdfließt durch jeden Actor, der vom Einstiegspunkt aus erreicht wird. - Über Cluster-Nodes hinweg reitet der Snapshot auf dem
Wire-Envelope — der empfangende Node stellt ihn wieder her, bevor
er
onReceiveaufruft. - Die
correlationIdtaucht in jeder Log-Zeile der Spur auf, damit dein Aggregator einen Multi-Actor-, Multi-Node-Request zu einem durchsuchbaren Thread zusammenfügen kann.
Aufgeschobene Arbeit und Tenant-Isolation
Abschnitt betitelt „Aufgeschobene Arbeit und Tenant-Isolation“Den Kontext zu erben ist genau das, was du für einen geradlinig
durchlaufenden Request willst: eine correlationId, jeder Hop. Es
hört in dem Moment auf, das Gewünschte zu sein, in dem Arbeit die
Runde überlebt, die sie gestartet hat — ein nicht awaitetes Promise,
ein später geflushter Puffer, eine im Batch geleerte Queue, ein für
die Zukunft scharfgemachter Retry.
AsyncLocalStorage bindet den Store, wenn die Async-Ressource
erzeugt wird. Ein in einer Runde gestartetes Promise behält deren
Kontext also für immer, und jedes tell seiner Fortsetzung stempelt
diesen Kontext auf das Envelope. Dient die aufgeschobene Arbeit einem
anderen Prinzipal als die auslösende Runde, reisen die Identifier des
einen Tenants mit den Nachrichten eines anderen mit — ein Datenleck,
nicht bloß eine verwirrende Log-Zeile:
// ✗ Leckt: der Drain erbt den Request, der den Flush ausgelöst hat.override onReceive(message: CollectorMessage): void { match(message) .with({ kind: 'buffer' }, (m) => this.onBuffer(m)) .with({ kind: 'drain' }, () => this.onDrain()) .exhaustive();}
private onDrain(): void { void (async () => { // niemand awaitet das hier for (const item of this.buffered.splice(0)) { await this.flush(item); this.sink.tell(item); // ← trägt den Tenant der DRAIN- } // Runde, für jedes Item })();}Zwei Primitive schließen die Lücke. Welches passt, hängt davon ab, wem die aufgeschobene Arbeit gehört.
runFresh — die Arbeit gehört niemandem
Abschnitt betitelt „runFresh — die Arbeit gehört niemandem“Nimm es an der Naht, an der Arbeit dem Aufrufer nicht mehr zurechenbar ist, der sie zufällig gestartet hat: eine Hintergrundschleife, ein Wartungslauf, ein Retry-Timer.
override onReceive(_message: string): void { LogContext.runFresh(async () => { await this.rebuildIndex(); this.sink.tell('index-rebuilt'); // ← trägt keine Tenant-Felder }).catch((error) => this.log.error('index rebuild failed', error));}Das .catch ist keine Zierde. Niemand awaitet dieses Promise, eine
Rejection hat also keinen Ort, an den sie gehen könnte: Auf Node ist
eine unbehandelte Rejection seit v15 standardmäßig fatal, ein
fehlschlagender Rebuild reißt also den Prozess mit, statt eine Zeile zu
loggen. Ein void vor dem Aufruf würde den Linter beruhigen und den
Absturz behalten.
Es ist die Umkehrung von with(): wo with erbt und ergänzt, startet
runFresh bewusst leer. Das schlägt sicher fehl — ein Feld, das
niemand gesetzt hat, kann nicht lecken — und ist leichter zu
überblicken, als sich zu merken, welche Schlüssel zu entfernen sind.
runEach — jedes Item gehört jemand anderem
Abschnitt betitelt „runEach — jedes Item gehört jemand anderem“Nimm es, wenn der Batch gemischt ist: eine in einer Runde geleerte Mailbox, ein Flush gepufferter Writes, über Tenants hinweg zusammengefasste Requests. Schnappschusse den Kontext jedes Items beim Einreihen — nur dann ist er noch aktuell — und spiele ihn beim Drainen wieder ein:
import type { LogContextEntry } from 'actor-ts';
// Die Queue überlebt die Runde, die sie gefüllt hat, das Feld braucht// also einen Typ — genau dafür ist `LogContextEntry` exportiert.private readonly buffered: Array<LogContextEntry<Item>> = [];
private onBuffer(m: BufferMessage): void { // Jetzt schnappschussen; die Drain-Runde ist viel zu spät. this.buffered.push({ context: LogContext.get(), item: m.item });}
private onDrain(): void { LogContext.runEach(this.buffered.splice(0), async (item) => { await this.flush(item); this.sink.tell(item); // ← der EIGENE Tenant je Item }).catch((error) => this.log.error('drain failed', error));}Die Einträge werden sequenziell abgearbeitet, jeder in seinem eigenen Scope, und der zur Drain-Zeit umgebende Kontext wird ignoriert statt gemerged.
Strukturiertes Logging — withFields + MDC zusammen
Abschnitt betitelt „Strukturiertes Logging — withFields + MDC zusammen“class PerSessionWorker extends Actor<...> { private log!: Logger;
override preStart(): void { this.log = this.context.log.withFields({ sessionId: this.id }); }
override async onReceive(message): Promise<void> { await LogContext.run({ requestId: message.requestId }, async () => { this.log.info('handling'); // → ... per-session-worker - handling {sessionId=abc, requestId=xyz} }); }}sessionId ist statisch (die Identität des Actors), requestId
ist per-Message-dynamisch. Beide landen im strukturierten Suffix;
der Log-Aggregator bekommt einen abfragefreundlichen Record.
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Actor —
this.logist Teil des Contexts, den jeder Actor hat. - Actor-System — die
logger/logLevel-Settings. - Tracing —
Span-ID-Propagation, gebaut auf demselben
AsyncLocalStorage-Primitive. - Observability — Metriken — die Metriken-Schnittstelle, separat von Logs, aber konzeptionell benachbart.
Die Logger- und
LogContext-API-Referenzen decken die
volle Schnittstelle ab.
