Skip to main content

Custom Instrumentation

Inside any actor handler, three observability access points are available through ActorContext:

  1. $ctx->tracer() → Monadial\Nexus\Observability\Trace\Tracer — create child spans for sub-operations
  2. $ctx->meter() → Monadial\Nexus\Observability\Metric\Meter — create custom counters, histograms, and gauges
  3. $ctx->currentSpan() → Monadial\Nexus\Observability\Trace\Span — the span for the message currently being processed

These are Nexus's vendor-neutral telemetry interfaces from nexus-observability — actor code never touches OTel types directly. All three return built-in no-op objects when observability is not wired in. No guard is needed.

Complete example​

order-actor.php
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Core\Actor\Behavior;
use Monadial\Nexus\Observability\Trace\StatusCode;

$behavior = Behavior::receive(static function (ActorContext $ctx, object $msg): Behavior {
// 1. Access the current message span (automatically created by Nexus).
// Add domain events or attributes to it directly.
$span = $ctx->currentSpan();
$span->addEvent('handler.started', ['message.type' => $msg::class]);

// 2. Create a child span for an expensive sub-operation.
$tracer = $ctx->tracer();
$childSpan = $tracer->startSpan('validate-order');

try {
// ... expensive operation ...
$childSpan->setAttribute('validation.result', 'passed');
$childSpan->setStatus(StatusCode::Ok);
} catch (\Throwable $e) {
$childSpan->recordException($e);
$childSpan->setStatus(StatusCode::Error, $e->getMessage());
throw $e;
} finally {
$childSpan->end();
}

// 3. Record a custom metric counter.
$meter = $ctx->meter();
$counter = $meter->counter(
'order.validations',
'{validation}',
'Number of order validations performed',
);
$counter->add(1, ['order.type' => 'standard']);

return Behavior::same();
});

Why $childSpan->end() goes in a finally block​

startSpan() activates the new span so any spans started while it is open become its children; end() closes the span and restores the previous active context. Always call end() in a finally block so a thrown exception does not leave a dangling active span.

Creating instruments once, not per-message​

Creating a counter or histogram inside the handler closure is safe — the underlying SDK caches instruments by name and returns the same instance on repeated calls. For hot paths, store the instrument in a closure variable to avoid the lookup:

cached-instrument.php
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Core\Actor\Behavior;
use Monadial\Nexus\Observability\Metric\Counter;

$counter = null;

$behavior = Behavior::receive(static function (ActorContext $ctx, object $msg) use (&$counter): Behavior {
if ($counter === null) {
$counter = $ctx->meter()->counter(
'order.validations',
'{validation}',
'Number of order validations performed',
);
}

$counter->add(1, ['order.type' => 'standard']);

return Behavior::same();
});
note

Counter is imported from Monadial\Nexus\Observability\Metric\Counter. The use (&$counter) closure capture by reference is intentional here — it is the variable holding the instrument that is mutated (from null to the counter), not the counter itself.

Zero cost when disabled​

When withObservability() is NOT called on NexusApp, all three context methods return Nexus's built-in no-op objects from nexus-observability:

  • $ctx->tracer() → Monadial\Nexus\Observability\Trace\NoopTracer
  • $ctx->meter() → Monadial\Nexus\Observability\Metric\NoopMeter
  • $ctx->currentSpan() → Monadial\Nexus\Observability\Trace\NoopSpan

These objects accept all method calls and discard data — no OTel classes are ever loaded. There are no memory allocations, no lock contention, and no overhead in the actor processing path.

Outside actors​

If you need tracing or metrics in a non-actor class (e.g., a repository or a domain service), inject the Observability provider via PSR-11 DI:

outside-actor.php
use Monadial\Nexus\Observability\Observability;

final class OrderRepository
{
public function __construct(private readonly Observability $observability) {}

public function save(object $order): void
{
$tracer = $this->observability->tracer();
$meter = $this->observability->meter();
// use $tracer and $meter as needed
}
}

tracer() and meter() return no-op objects when observability is disabled, so this pattern is safe with or without observability wired in.