Skip to main content

Observability

OpenTelemetry traces, metrics, and logs

Looking for distributed tracing, OTel metrics export, or trace/log correlation? The Observability guide covers the full OTEL integration — spans, metrics catalog, custom instrumentation, and production wiring.

The HTTP layer uses PSR-3 logging, PSR-14 event dispatch, and PSR-20 clock throughout. This page covers what the framework logs, how to add per-request context via MDC, how to keep logging off the hot path, and how to wire PSR-14 events for external observability tools.

What the framework logs

When you configure a LoggerInterface via $app->withLogger($logger), the framework emits events at well-defined points:

EventLevelSource
worker start / thread startinfoAdapter
request receiveddebugRouterMiddleware
route matcheddebugRouterMiddleware (also dispatches RouteMatched PSR-14 event)
route not foundinfoRouterMiddleware
handler errorerrorExceptionHandlerMiddleware
response sentdebugAdapter
worker stop / thread stopinfoAdapter
WebSocket open / message / closedebugWebSocketDispatcher
WebSocket channel actor spawndebugChannelActorRegistry

Most events are debug-level — silent in production unless you set minLevel(Level::Debug).

Logging in handlers

Inject a LoggerInterface like any other service:

src/Http/Handlers/CreateOrderHandler.php
<?php

declare(strict_types=1);

use Monadial\Nexus\Core\Actor\ActorRef;
use Monadial\Nexus\Http\Handler\Attribute\FromActor;
use Monadial\Nexus\Http\Handler\Attribute\FromBody;
use Monadial\Nexus\Http\Handler\Attribute\FromService;
use Monadial\Nexus\Http\Response\JsonResponse;
use Monadial\Nexus\Runtime\Duration;
use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};
use Psr\Log\LoggerInterface;

final class CreateOrderHandler
{
public function __construct(
#[FromActor('orders')] private readonly ActorRef $orders,
#[FromService(LoggerInterface::class)] private readonly LoggerInterface $log,
) {}

public function __invoke(
ServerRequestInterface $req,
#[FromBody] CreateOrderDto $dto,
): ResponseInterface {
$this->log->info('placing order', ['sku' => $dto->sku]);
$orderId = $this->orders->ask(new Place($dto), Duration::seconds(2))->await();
$this->log->info('order placed', ['id' => $orderId]);

return JsonResponse::ok(['id' => $orderId])->withStatus(201);
}
}

MDC (mapped diagnostic context)

MDC is the ambient metadata bucket that every log record picks up automatically — no need to thread it through every log call. Two tiers:

  • Mdc::putStatic(key, value) — Thread-wide. Survives across coroutines. Set once at thread boot.
  • Mdc::put(key, value) — Coroutine-scoped (Swoole) or fiber-scoped (Fiber). Resets between requests automatically.

Per-thread static context

Set at boot; attached to every record from that thread:

src/bootstrap.php
SwooleThreadServer::run($config, function (ActorSystem $system, WorkerNode $node) {
Mdc::putStatic('host', gethostname() ?: 'unknown');
Mdc::putStatic('threadId', $node->workerId());
Mdc::putStatic('service', 'orders-api');

return WsApplication::create($system)->/* … */;
});

Per-request coroutine context

Set per request inside a middleware:

src/Http/Middleware/RequestContextMiddleware.php
<?php

declare(strict_types=1);

use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};
use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface};

final class RequestContextMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $req,
RequestHandlerInterface $next,
): ResponseInterface {
Mdc::put('requestId', $req->getHeaderLine('X-Request-Id') ?: bin2hex(random_bytes(8)));
Mdc::put('method', $req->getMethod());
Mdc::put('path', $req->getUri()->getPath());

return $next->handle($req);
}
}

Register globally:

src/bootstrap.php
$app->middleware(RequestContextMiddleware::class);

Every log call from that point — whether from middleware, the handler, or actors invoked via ask() — picks up the request context without the developer touching it.

Access logging

A standard pattern — log every request as a structured line:

src/Http/Middleware/AccessLogMiddleware.php
<?php

declare(strict_types=1);

use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};
use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface};
use Psr\Log\LoggerInterface;

final class AccessLogMiddleware implements MiddlewareInterface
{
public function __construct(private readonly LoggerInterface $log) {}

public function process(
ServerRequestInterface $req,
RequestHandlerInterface $next,
): ResponseInterface {
$start = hrtime(true);

try {
$response = $next->handle($req);
$status = $response->getStatusCode();
} catch (\Throwable $e) {
$status = 500;
throw $e;
} finally {
$ms = (hrtime(true) - $start) / 1_000_000;
$this->log->info('{method} {path} {status} ({ms}ms)', [
'method' => $req->getMethod(),
'path' => $req->getUri()->getPath(),
'status' => $status,
'ms' => round($ms, 2),
]);
}

return $response;
}
}

Pair with RequestContextMiddleware so the access log line carries the same requestId as every other line from that request.

Async logging

Calling $log->info(...) blocks until the formatting and write complete. On a hot request path, this matters. Two escape paths:

Actor-backed logger

The nexus-logger package wraps handlers in a LogActor turn — $log->info(...) returns as soon as the record is enqueued; formatting and I/O happen on the actor's mailbox:

src/bootstrap.php
use Monadial\Nexus\Logger\Handler\ConsoleHandler;
use Monadial\Nexus\Logger\Formatter\LineFormatter;
use Monadial\Nexus\Logger\{Level, NexusLogger};

$logger = NexusLogger::create($system, 'app')
->minLevel(Level::Info)
->handler(new ConsoleHandler(STDOUT, new LineFormatter()))
->build();

See nexus-logger architecture for the full picture.

Thread\Queue sink (thread mode only)

For multi-thread servers, push formatted lines onto a shared Swoole\Thread\Queue that a dedicated writer thread drains:

src/bootstrap.php
use Monadial\Nexus\Logger\Swoole\ThreadQueueHandler;
use Swoole\Thread\Queue;

$logQueue = new Queue();

SwooleThreadServer::run(
SwooleThreadConfig::bind('0.0.0.0', 8080)
->threads(8)
->withLogQueue($logQueue),
static function (ActorSystem $system, WorkerNode $node) use ($logQueue) {
$logger = NexusLogger::create($system, "thread-{$node->workerId()}")
->handler(new ThreadQueueHandler($logQueue, new LineFormatter()))
->build();

return WsApplication::create($system)->withLogger($logger)->compile();
},
);

One writer thread, one file descriptor, no locks. This pattern brings logging overhead to approximately 3% RPS at sustained load (vs approximately 7% with synchronous handlers in the same configuration).

Capturing the call site

For debug and error logs, CallerInfoProcessor walks the backtrace at log time and stamps class, function, file, and line into Record.extra:

src/bootstrap.php
use Monadial\Nexus\Logger\Processor\CallerInfoProcessor;

$logger = NexusLogger::create($system, 'app')
->processor(CallerInfoProcessor::onlyFor(Level::Debug, Level::Error, Level::Critical))
->handler(new ConsoleHandler(STDOUT, new LineFormatter()))
->build();

onlyFor() restricts the backtrace walk to the listed levels. Info-level traffic skips it — debug_backtrace() is the most expensive step in the pipeline and is unnecessary for routine info logs.

PSR-14 event dispatch

The router dispatches PSR-14 events for integration with OpenTelemetry, custom metrics, or external observability tools:

Event classFired when
RouteMatchedAfter successful match, before middleware runs
ResponseSentAfter the response is written to the socket
WebSocketOpenedAfter upgrade succeeds
WebSocketClosedAfter close frame is sent

Wire a PSR-14 dispatcher via the HttpApp::create constructor (see nexus-http) and add your listeners there.

See also