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

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(msg): 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 are no-ops — this.log.debug(...) on a system configured for info doesn’t even evaluate the arguments (the level check happens first).

System-wide via the settings:

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

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

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

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(msg, extra1, extra2) land under an args array — log aggregators index nested keys automatically.

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, otelLogger } from 'actor-ts';
// (SDK setup: register a LoggerProvider + OTLP exporter — out of scope here.)
const system = ActorSystem.create('my-app', {
logger: otelLogger({ api: logsApi }),
});

@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(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 { /* 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 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 returns a new logger with the fields stamped on every emit. Useful for component-level tagging that doesn’t change across messages.

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 } 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(msg): 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. Three operations:

MethodWhat it does
LogContext.run(ctx, fn)Runs fn with ctx as the current context.
LogContext.with(extra, fn)Runs fn with { ...current, ...extra } as the context.
LogContext.get()Reads the current context (empty object if none active).

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

When you call ref.tell(msg) 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.

Structured logging — withFields + MDC together

Section titled “Structured logging — withFields + MDC together”
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 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.