Zum Inhalt springen
Deutsch

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).

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.

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 complete

Der 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:

WoWann 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
});

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.

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.

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.

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:

MethodeWas 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).

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 correlationId fließ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 onReceive aufruft.
  • Die correlationId taucht in jeder Log-Zeile der Spur auf, damit dein Aggregator einen Multi-Actor-, Multi-Node-Request zu einem durchsuchbaren Thread zusammenfügen kann.

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.

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.

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.

  • Actorthis.log ist 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.