コンテンツにスキップ
日本語

Logging

このコンテンツはまだ日本語訳がありません。

Every actor has a logger at this.log, automatically bound to the actor’s path:

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
}
}

Four methods on every 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;
}

Calls below the configured level emit nothing — this.log.debug(...) on a system configured for info produces no output. The arguments are still evaluated first, though (they’re ordinary function arguments), so guard expensive payloads with a manual level check (see below).

System-wide via the settings:

import { ActorSystem, ActorSystemOptions, LogLevel } from 'actor-ts';
const actorSystemOptions = ActorSystemOptions.create().withLogLevel(LogLevel.Debug);
const system = ActorSystem.create('my-app', actorSystemOptions);

Or via the env / config file (actor-ts.logger.level = "debug" in application.conf). Five levels: debug / info / warn / error / off. Default is info.

A path is an address, not a name. Under sharding it grows into something no one reads twice:

[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

Override displayName() and the actor says who it is, once, instead of you repeating it in every message:

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

The name is added, not substituted: source stays the path, so nothing you correlate on moves. It defaults to the path, and a name equal to the path is dropped rather than printed twice — an actor that doesn’t override this logs exactly as it always did.

Three ways to set it, in precedence order:

WhereWhen to reach for it
context.setDisplayName(name)The name is only known at runtime — after recovery, after the first message. Also the way in for Behaviors actors, which all share one TypedActor class and have no subclass of yours to override.
ActorOptions.withDisplayName(name)The spawn site knows the name and the actor doesn’t — a sharded entity, a singleton, anything the framework constructs.
displayName()The usual case: the actor knows what it is.
import { ActorOptions } from 'actor-ts';
const workerOptions = ActorOptions.create().withDisplayName('ingest-worker');
system.spawn(IngestWorker, 'ingest', workerOptions);

The name is a label for humans, never an identity. Metric labels, tracing attributes, dead letters, ActorRef.toString() and every cluster-wire identifier keep using the path — so a display name is free to be ambiguous, shared between actors, or to change mid-flight. DevTools shows it as the row label, with the path still in the tooltip.

Structured JSON logs — built-in JsonLogger

Section titled “Structured JSON logs — built-in JsonLogger”

For log-aggregation pipelines (Loki, ELK, Datadog, CloudWatch, Splunk, etc.) you want one JSON object per record, not the human-readable text the default ConsoleLogger emits. JsonLogger is the shipped implementation:

import { ActorSystem, ActorSystemOptions, JsonLogger } from 'actor-ts';
const actorSystemOptions = ActorSystemOptions.create().withLogger(new JsonLogger());
const system = ActorSystem.create('my-app', actorSystemOptions);

Output (one \n-terminated JSON object per 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}]}

Every record carries ts (ISO-8601), level, msg, and the merged static + dynamic MDC (static from withFields, dynamic from LogContext.run, with dynamic winning on key collision). Extra positional ...args from log.info(message, extra1, extra2) land under an args array — log aggregators index nested keys automatically.

An actor that named itself adds a displayName key beside source, where it is filterable on its own — ConsoleLogger folds the two into one line, structured output keeps them apart:

{"ts":"2026-08-04T16:50:13.450Z","level":"info",
"source":"actor-ts://shop/.../entity-da76e1eb-...",
"displayName":"User(test-user-590)",
"msg":"recovery complete"}

Error instances serialise as { name, message, stack } instead of the default "{}" (Error’s enumerable surface is empty). Circular references, BigInt, and functions are sanitised so a log call never throws.

JsonLogger’s constructor takes an optional sink — by default it writes to process.stdout, the right pipe for the Docker logging driver, the Kubernetes log scraper, vector, fluent-bit, and jq:

new JsonLogger(LogLevel.Info, '', {}, {
write: (line) => process.stderr.write(line), // redirect to stderr
});

For an OpenTelemetry Collector wired up via @opentelemetry/sdk-logs + OTLPLogExporter, bridge through the OTel Logs API instead of stdout-JSON:

import * as logsApi from '@opentelemetry/api-logs';
import { ActorSystem, ActorSystemOptions, otelLogger } from 'actor-ts';
// (SDK setup: register a LoggerProvider + OTLP exporter — out of scope here.)
const actorSystemOptions = ActorSystemOptions.create().withLogger(otelLogger({ api: logsApi }));
const system = ActorSystem.create('my-app', actorSystemOptions);

@opentelemetry/api-logs is an optional peer dep — the framework never imports it, you bring your existing namespace import. Every this.log.info(...) inside an actor lands as an OTel LogRecord with severity mapped to OTel’s standard severity-number range, the actor’s path on source, the merged MDC on attributes, and the active span’s traceId/spanId automatically linked when tracing is enabled in the same process.

The Logger interface is small enough to implement directly if neither built-in fits — e.g. you want a specific binary wire format, you want to route to multiple sinks via fan-out, or you need to wrap an existing log library. Same shape as the built- ins:

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 { /* return a bound copy */ return this; }
withFields(fields: LogContextData): Logger { /* return a bound copy */ return this; }
}

The framework calls withSource once per actor to bind the actor’s path; you don’t have to do that yourself.

Static fields — same value on every record emitted by this logger:

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 returns a new logger with the fields stamped on every emit. Useful for component-level tagging that doesn’t change across messages. Bind it in preStart rather than a field initializer — this.context isn’t available until the actor is attached, so a field initializer would throw at construction.

For fields that vary per request rather than per actor — a correlation id, a request id, a user id — use the LogContext MDC. Set it at the entry point; every log call inside reads it automatically:

import { LogContext, randomUuid } from 'actor-ts';
// HTTP request handler — wraps the actor work in a context 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);
});
});
// Inside any actor reached via `tell` / `ask` from there:
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 is backed by AsyncLocalStorage — the context propagates across awaits, tells, and cluster hops. The operations:

MethodWhat it does
LogContext.run(context, callback)Runs callback with context as the current context.
LogContext.with(extra, callback)Runs callback with { ...current, ...extra } as the context.
LogContext.get()Reads the current context (empty object if none active).
LogContext.snapshot()Copies the current context into a fresh, mutable plain object.
LogContext.runFresh(callback)Runs callback with the context emptied, ignoring whatever is ambient.
LogContext.runEach(entries, callback)Runs callback once per entry, each under the context captured with it.

get() returns the live readonly context — the same reference for the whole scope, so never hold onto it past the scope or hand it to something that might mutate it. snapshot() is the one to reach for at a boundary: it returns a fresh copy on every call, safe to keep, mutate, or feed to a serialiser.

Static fields (withFields) and dynamic MDC merge at emit time; dynamic wins on key collision (innermost-scope-wins intuition).

When you call ref.tell(message) inside a LogContext.run scope, the runtime snapshots the current context onto the envelope. The receiving actor’s onReceive runs under a fresh LogContext.run of that snapshot. This means:

  • A single correlationId flows through every actor reached from the entry point.
  • Across cluster nodes, the snapshot rides on the wire envelope — the receiving node restores it before invoking onReceive.
  • The correlationId shows up in every log line in the trail, letting your aggregator stitch a multi-actor, multi-node request into one searchable thread.

Inheriting the context is exactly what you want for a request that flows straight through: one correlationId, every hop. It stops being what you want the moment work outlives the turn that started it — an un-awaited promise, a buffer flushed later, a queue drained in a batch, a retry armed for the future.

AsyncLocalStorage binds a store when the async resource is created. So a promise started inside a turn keeps that turn’s context forever, and every tell its continuation makes stamps that context onto the envelope. If the deferred work serves a different principal than the turn that triggered it, one tenant’s identifiers travel with another tenant’s messages — a data leak, not just a confusing log line:

// ✗ Leaks: the drain inherits whichever request triggered the flush.
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 () => { // nobody awaits this
for (const item of this.buffered.splice(0)) {
await this.flush(item);
this.sink.tell(item); // ← stamped with the DRAINING
} // turn's tenant, for every item
})();
}

Two primitives close the hole. Which one fits depends on whom the deferred work belongs to.

Use it at the seam where work stops being attributable to the caller that happened to start it: a background loop, a maintenance sweep, a retry timer.

override onReceive(_message: string): void {
LogContext.runFresh(async () => {
await this.rebuildIndex();
this.sink.tell('index-rebuilt'); // ← carries no tenant fields
}).catch((error) => this.log.error('index rebuild failed', error));
}

The .catch is not decoration. Nothing awaits this promise, so a rejection has nowhere to go: on Node an unhandled rejection has been fatal by default since v15, so a failing rebuild takes the process down instead of logging a line. void in front of the call would silence the linter and keep the crash.

It is the inverse of with(): where with inherits and adds, runFresh deliberately starts from empty. That fails safe — a field nobody set cannot leak — and it is easier to reason about than remembering which keys to strip.

runEach — each item belongs to someone different

Section titled “runEach — each item belongs to someone different”

Use it when the batch is a mix: a mailbox drained in one turn, a flush of buffered writes, requests coalesced across tenants. Capture each item’s context at enqueue time — that is the only moment it is still current — then replay it at drain time:

import type { LogContextEntry } from 'actor-ts';
// The queue outlives the turn that filled it, so the field has to be
// typed — which is what `LogContextEntry` is exported for.
private readonly buffered: Array<LogContextEntry<Item>> = [];
private onBuffer(m: BufferMessage): void {
// Capture now; the draining turn is far too late.
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); // ← each item's OWN tenant
}).catch((error) => this.log.error('drain failed', error));
}

Entries are processed sequentially, each inside its own scope, and the context ambient at drain time is ignored rather than merged.

Structured logging — withFields + MDC together

Section titled “Structured logging — withFields + MDC together”
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 is static (the actor’s identity), requestId is per-message dynamic. Both end up in the structured suffix; the log aggregator gets a query-friendly record.

  • Actorthis.log is part of the context every actor has.
  • Actor system — the logger / logLevel settings.
  • Tracing — span-id propagation built on the same AsyncLocalStorage primitive.
  • Observability — Metrics — the metrics surface, separate from logs but conceptually adjacent.

The Logger and LogContext API references cover the full surface.