symfony-opcua · master
Docs · Session manager

Monitoring the daemon

Liveness probes, session counts, log channels, and a Symfony-native /health/opcua endpoint pattern.

A production daemon needs five questions answered at any moment:

  1. Is it up?
  2. How many sessions are open?
  3. Are notifications flowing?
  4. What was the last error?
  5. How is memory looking?

This page covers the Symfony-native answers.

1 — Liveness probe controller

php src/Controller/HealthController.php
namespace App\Controller;

use PhpOpcua\SymfonyOpcua\OpcuaManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class HealthController extends AbstractController
{
    public function __construct(private OpcuaManager $opcua) {}

    #[Route('/health/opcua', methods: ['GET'])]
    public function opcua(): JsonResponse
    {
        try {
            $alive = $this->opcua->isSessionManagerRunning();
        } catch (\Throwable $e) {
            return $this->json(['status' => 'down', 'error' => $e->getMessage()], 503);
        }

        return $this->json([
            'status' => $alive ? 'up' : 'direct-mode',
        ]);
    }
}
  • up — the daemon's socket file exists (a passive file_exists() check; not an active ping).
  • direct-mode — the socket doesn't exist, the bundle would fall through to direct connections.
  • 503 — probe itself failed.

Wire into your liveness aggregator (Kubernetes probe, Pingdom, Better Uptime).

2 — Detailed health probe

OpcuaManager does not expose a daemonStats() method. The only daemon-side probe it ships is isSessionManagerRunning() (a file_exists check on the Unix socket — see the previous section).

For richer daemon stats — session count, uptime, in-flight requests — open an IPC connection directly and call a daemon-side admin verb if your opcua-session-manager version supports one. Alternatively, surface the same information via a listener-side counter (see "Notification flow" below) plus systemd's MainPID / log inspection.

3 — Notification flow

Track event throughput with a tiny listener + cache counter:

php src/EventListener/TrackOpcuaFlow.php
namespace App\EventListener;

use PhpOpcua\Client\Event\DataChangeReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class TrackOpcuaFlow
{
    public function __construct(private CacheInterface $cache) {}

    #[AsEventListener]
    public function __invoke(DataChangeReceived $event): void
    {
        $total = $this->cache->get('opcua.notif.total', function (ItemInterface $i) {
            $i->expiresAfter(86400);
            return 0;
        });
        $this->cache->delete('opcua.notif.total');
        $this->cache->get('opcua.notif.total', function (ItemInterface $i) use ($total) {
            $i->expiresAfter(86400);
            return $total + 1;
        });

        $this->cache->delete('opcua.notif.last');
        $this->cache->get('opcua.notif.last', fn(ItemInterface $i) =>
            $i->expiresAfter(3600) ?? (new \DateTimeImmutable())->format('c')
        );
    }
}

Then probe:

php flow endpoint
#[Route('/health/opcua/flow', methods: ['GET'])]
public function flow(CacheInterface $cache): JsonResponse
{
    $total = $cache->get('opcua.notif.total', fn() => 0);
    $last  = $cache->get('opcua.notif.last', fn() => null);

    $stale = $last !== null
        && (new \DateTimeImmutable())->diff(new \DateTimeImmutable($last))->i > 5;

    return $this->json([
        'total'             => $total,
        'last_notification' => $last,
        'stale'             => $stale,
    ], $stale ? 503 : 200);
}

If you expect notifications every few seconds and the last one was 5 minutes ago, something's wrong upstream.

For high-volume deployments, don't use the cache contract for counters — use a dedicated Redis counter or a metrics exporter (see below). The example above is fine for moderate throughput.

4 — Last error

The daemon logs to your Monolog OPCUA_LOG_CHANNEL. For real-time error visibility:

text config/packages/monolog.yaml
monolog:
    channels: ['opcua']
    handlers:
        opcua_file:
            type: rotating_file
            path: '%kernel.logs_dir%/opcua.log'
            max_files: 14
            channels: ['opcua']
            level: info
        opcua_slack:
            type: slack
            token: '%env(SLACK_TOKEN)%'
            channel: '#ops-alerts'
            channels: ['opcua']
            level: error
            include_extra: true

Daemon errors land both in the daily file (for forensics) and in Slack (for immediate response).

5 — Memory

The daemon's memory grows with session count + monitored items. 5 sessions, 100 items = ~80-150 MB. Spikes warrant a look.

Probe via the OS:

bash terminal
systemctl status opcua-session-manager   # shows RSS
ps -o rss,pid,command -p "$(pgrep -f opcua:session)"

…or via daemonStats() if you've added memory to its response.

Prometheus exporter

A scheduled Symfony command exports daemon stats to a Prometheus push gateway:

php src/Command/ExportOpcuaMetricsCommand.php
namespace App\Command;

use PhpOpcua\SymfonyOpcua\OpcuaManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

#[AsCommand(name: 'app:opcua:metrics')]
final class ExportOpcuaMetricsCommand extends Command
{
    public function __construct(
        private OpcuaManager $opcua,
        private HttpClientInterface $http,
        private string $pushGatewayUrl,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // Push the only signal the manager natively exposes — whether the
        // daemon's Unix socket exists. For richer stats (session count,
        // uptime) you'll need to wire your own IPC-level probe to the
        // daemon (see opcua-session-manager's IPC docs) or accumulate
        // counters from event listeners.
        $alive = (int) $this->opcua->isSessionManagerRunning();

        $metrics = sprintf("opcua_socket_present %d\n", $alive);

        $this->http->request('POST', $this->pushGatewayUrl . '/metrics/job/opcua', [
            'body' => $metrics,
        ]);

        return Command::SUCCESS;
    }
}

Schedule every 15 s via Symfony Scheduler:

php schedule
use Symfony\Component\Console\Messenger\RunCommandMessage;

#[AsSchedule('opcua-metrics')]
final class OpcuaMetricsSchedule implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return (new Schedule())
            ->add(RecurringMessage::every('15 seconds', new RunCommandMessage('app:opcua:metrics')));
    }
}

Profiler / data collectors

Symfony's WebProfilerBundle can show OPC UA calls — see Observability · Profiler and data collectors.

Common alarm rules

Rule Trigger / threshold Likely cause
isSessionManagerRunning() returns false > 1 minute Daemon down, socket gone
No DataChangeReceived > 5 minutes with auto-publish on All subscriptions dropped
ERROR-level logs > 10/minute Something's actively wrong
Daemon memory > 1.5× normal Leak in a third-party module/listener

Debugging from the terminal

For one-shot diagnostics:

bash netcat probe
# The IPC envelope is length-prefixed JSON. The request shape is
# {"command":"...", "sessionId":"...", "method":"...", "params":{}, "authToken":"..."}.
# Use the helper script in opcua-session-manager's docs (it handles
# the 4-byte length prefix).
opcua-session-manager-probe /var/run/opcua/sessions.sock

See opcua-session-manager's debugging-with-netcat recipe.

You've finished Session manager. Continue with Events overview for the listener side.

Documentation