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).
Configuring the level
Section titled “Configuring the level”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.
Naming an actor — displayName()
Section titled “Naming an actor — displayName()”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 completeOverride 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 completeThe 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:
| Where | When 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});OTLP-Logs pipeline — otelLogger
Section titled “OTLP-Logs pipeline — otelLogger”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.
Custom logger
Section titled “Custom logger”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.
Structured fields via withFields
Section titled “Structured fields via withFields”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.
Dynamic context — LogContext (MDC)
Section titled “Dynamic context — LogContext (MDC)”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:
| Method | What 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).
How MDC propagates across tell
Section titled “How MDC propagates across tell”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
correlationIdflows 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
correlationIdshows up in every log line in the trail, letting your aggregator stitch a multi-actor, multi-node request into one searchable thread.
Deferred work and tenant isolation
Section titled “Deferred work and tenant isolation”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.
runFresh — the work belongs to nobody
Section titled “runFresh — the work belongs to nobody”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.
Where to next
Section titled “Where to next”- Actor —
this.logis part of the context every actor has. - Actor system — the
logger/logLevelsettings. - Tracing —
span-id propagation built on the same
AsyncLocalStorageprimitive. - Observability — Metrics — the metrics surface, separate from logs but conceptually adjacent.
The Logger and
LogContext API references
cover the full surface.
