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(msg): 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 sind No-Ops — this.log.debug(...) auf einem System, das für info konfiguriert ist, evaluiert nicht einmal die Argumente (der Level-Check passiert zuerst).

Systemweit über die Settings:

const system = ActorSystem.create('my-app', { logLevel: 'debug' });

Oder über die Env- / Config-Datei (actor-ts.log-level = "debug" in application.conf). Fünf Level: debug / info / warn / error / silent. Default ist info.

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, JsonLogger } from 'actor-ts';
const system = ActorSystem.create('my-app', {
logger: new JsonLogger(),
});

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(msg, extra1, extra2) landen unter einem args-Array — Log-Aggregators indexieren verschachtelte Keys automatisch.

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, otelLogger } from 'actor-ts';
// (SDK-Setup: LoggerProvider + OTLP-Exporter registrieren — außerhalb des Scope.)
const system = ActorSystem.create('my-app', {
logger: otelLogger({ api: logsApi }),
});

@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(msg: string, ...args: unknown[]): void { /* ... */ }
info(msg: string, ...args: unknown[]): void { /* ... */ }
warn(msg: string, ...args: unknown[]): void { /* ... */ }
error(msg: 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 readonly log = this.context.log.withFields({
component: 'shard-coordinator',
shardId: this.shardId,
});
override onReceive(msg): 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.

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 } 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(msg): 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. Drei Operationen:

MethodeWas sie tut
LogContext.run(ctx, fn)Führt fn mit ctx als aktuellem Kontext aus.
LogContext.with(extra, fn)Führt fn mit { ...current, ...extra } als Kontext aus.
LogContext.get()Liest den aktuellen Kontext (leeres Objekt, wenn keiner aktiv).

Statische Felder (withFields) und dynamische MDC mergen zur Emit-Zeit; dynamisch gewinnt bei Schlüssel-Kollision (innermost-scope-wins-Intuition).

Wenn du ref.tell(msg) 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.

Strukturiertes Logging — withFields + MDC zusammen

Abschnitt betitelt „Strukturiertes Logging — withFields + MDC zusammen“
class PerSessionWorker extends Actor<...> {
private readonly log = this.context.log.withFields({ sessionId: this.id });
override async onReceive(msg): Promise<void> {
await LogContext.run({ requestId: msg.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.