json-rpc-server

11 — Observability

The bundle ships four observability stacks out of the box — all opt-in, all reading the same PSR-14 events the dispatcher fires. You can mix and match; nothing competes with anything.

Stack Switch Best for
PSR-14 events always on writing your own listener
Symfony Web Profiler json_rpc_server.profiler.enabled (debug only) local development
PSR-3 logging json_rpc_server.logging.enabled structured app logs
Sentry json_rpc_server.sentry.enabled issue tracking with breadcrumbs / tags / spans
OpenTelemetry json_rpc_server.opentelemetry.enabled vendor-neutral traces / metrics / propagation

PSR-14 events

The dispatcher fires three events for every RPC call:

namespace Knetesin\JsonRpcServerBundle\Event;

final readonly class MethodInvocationStartedEvent {
    public MethodMetadata $method;
    public RpcParams      $params;
}

final readonly class MethodInvocationCompletedEvent {
    public MethodMetadata $method;
    public RpcParams      $params;
    public mixed          $result;       // normalized form
    public float          $durationSec;
    public bool           $cacheHit;
}

final readonly class MethodInvocationFailedEvent {
    public MethodMetadata $method;
    public RpcParams      $params;
    public \Throwable     $exception;
    public float          $durationSec;
}

Started fires once the method name is known (after registry lookup, before argument resolution). Then exactly one of Completed or Failed.

That means client-side failures (InvalidParamsException / -32602, AccessDeniedException, rate limits, denormalize errors) still produce rpc.call.failed, a Web Profiler RPC row, and optional Sentry/OTel hooks — the handler never runs, but observability does.

For streaming methods, Completed fires the moment the iterator is returned (before iteration). Three additional events let you track iteration itself:

final readonly class StreamRowEmittedEvent {
    public MethodMetadata $method;
    public mixed          $row;
    public int            $index;
}

final readonly class StreamIterationCompletedEvent {
    public MethodMetadata $method;
    public int            $rowCount;
    public float          $durationSec;
}

final readonly class StreamIterationFailedEvent {
    public MethodMetadata $method;
    public \Throwable     $exception;
    public int            $rowCount;
    public float          $durationSec;
}

Writing a subscriber

use Knetesin\JsonRpcServerBundle\Event\MethodInvocationCompletedEvent;
use Knetesin\JsonRpcServerBundle\Event\MethodInvocationFailedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class AuditSubscriber implements EventSubscriberInterface
{
    public function __construct(private readonly Audit $audit) {}

    public static function getSubscribedEvents(): array
    {
        return [
            MethodInvocationCompletedEvent::class => 'onCompleted',
            MethodInvocationFailedEvent::class    => 'onFailed',
        ];
    }

    public function onCompleted(MethodInvocationCompletedEvent $e): void
    {
        if ($this->audit->isAudited($e->method->name)) {
            $this->audit->log(
                method: $e->method->name,
                params: $e->params->all(),
                result: $e->result,
            );
        }
    }

    public function onFailed(MethodInvocationFailedEvent $e): void { /* … */ }
}

With Symfony’s default autoconfigure: true, that’s all the wiring you need.

Event ordering with caching

On a cache hit:

Started   (params)
Completed (params, cached result, cacheHit=true, duration=0.0)

The handler never runs. On a miss, the handler runs and Completed fires with the fresh result and cacheHit=false. On a failure, Failed fires instead — Completed and Failed never both fire for the same call.


Symfony Web Profiler

When kernel.debug = true and symfony/web-profiler-bundle is installed, the bundle adds a RPC panel:

json_rpc_server:
    profiler:
        enabled: true   # default; no-op outside kernel.debug

In production the subscriber is registered but never invoked — the framework profiler itself is off, so the cost is nil. Leave enabled: true.

composer require --dev symfony/web-profiler-bundle

PSR-3 logging

A built-in subscriber writes one log line per outcome — no code to write.

json_rpc_server:
    logging:
        enabled: true
        channel: ~                  # null = the default `logger` service
                                    # e.g. monolog.logger.rpc for a dedicated channel
        level_started:   debug      # rpc.call.started
        level_completed: info       # rpc.call.completed
        level_failed:    warning    # rpc.call.failed
        log_params: true            # include request params in context
        log_result: false           # include result (verbose)
        slow_threshold_ms: ~        # escalate slow calls to level_failed

Output (illustrative):

[2026-05-22T15:00:00+00:00] app.INFO: rpc.call.completed
    {"method":"user.update","duration_ms":42,"cache_hit":false,"params":{"id":7}}

Routing to a dedicated channel

# config/packages/monolog.yaml
monolog:
    channels: ['rpc']
    handlers:
        rpc_file:
            type: stream
            path: '%kernel.logs_dir%/rpc.log'
            channels: ['rpc']

# config/packages/json_rpc_server.yaml
json_rpc_server:
    logging:
        enabled: true
        channel: monolog.logger.rpc

When the built-in subscriber doesn’t fit your needs (custom redaction, sampling, structured fields) turn it off and write your own — the events are the canonical extension point.


Sentry

Installs into Sentry via sentry/sentry-symfony.

composer require sentry/sentry-symfony
json_rpc_server:
    sentry:
        enabled: true
        breadcrumbs: true       # rpc-category breadcrumb on every call
        tag_method: true        # sets rpc.method tag while a call is in flight
        transactions: false     # child spans for Performance Monitoring
        ignore_exceptions:      # client-side errors stay invisible
            - Knetesin\JsonRpcServerBundle\Exception\InvalidParamsException
            - Knetesin\JsonRpcServerBundle\Exception\InvalidRequestException
            - Knetesin\JsonRpcServerBundle\Exception\MethodNotFoundException
            - Knetesin\JsonRpcServerBundle\Exception\ParseException
            - Knetesin\JsonRpcServerBundle\Exception\AccessDeniedException
            - Knetesin\JsonRpcServerBundle\Exception\RateLimitExceededException

In Sentry every issue from a handler gets:

Unhandled exceptions still flow into Sentry via the PSR-3 logger as before — this subscriber is purely about extra context. Exceptions in ignore_exceptions skip the error breadcrumb / span (they still produce rpc.call.failed in application logs when logging.enabled is true). Remove InvalidParamsException from ignore_exceptions when you want validation mistakes as Sentry issues / error spans — the defaults keep client errors off SLO dashboards. To filter them out of Sentry entirely use a before_send hook in Sentry config.

The subscriber registers only when both flags are true: enabled: true and sentry/sentry-symfony installed. Dev without Sentry silently no-ops.


OpenTelemetry

Vendor-neutral. Works with Jaeger, Datadog, Grafana Tempo, Honeycomb, AWS X-Ray, Google Cloud Trace, New Relic, Lightstep — anything that speaks OTLP.

composer require open-telemetry/sdk
# plus an exporter for your backend, e.g.:
# composer require open-telemetry/exporter-otlp

Initialize the SDK once per process (typically in config/bootstrap.php — see the PHP OTel SDK docs). The bundle picks the tracer / meter up from the OTel global provider.

json_rpc_server:
    opentelemetry:
        enabled: true
        tracer_name: 'json-rpc'
        traces: true                # SERVER-kind span per RPC call
        metrics: true               # rpc.server.duration + rpc.server.requests
        propagate_traceparent: true # join the parent trace from upstream HTTP
        record_params: false        # attach params as span attribute (PII-warning)
        record_result: false        # attach result as span attribute (verbose)
        record_max_chars: 2048      # truncate the above
        stream:
            record_row_count: true  # cheap — sets rpc.stream.row_count attribute
            span_per_row: false     # expensive — one extra span per emitted row
        ignore_exceptions: [...]    # same set as Sentry

What ends up in your backend

The subscriber registers only when both enabled: true and open-telemetry/sdk is installed. Without the SDK the bundle has zero footprint.

Custom attributes

Need an attribute the bundle doesn’t add by default? Write your own subscriber against the same events:

public function onStarted(MethodInvocationStartedEvent $e): void
{
    Span::getCurrent()->setAttribute('app.tenant', $this->tenantResolver->id());
}

Span context is already active during the call thanks to the bundle’s subscriber — there’s nothing else to wire.


Combining stacks

Nothing stops you running all four at once. A typical production setup:

The four subscribers don’t talk to each other — they each read the events independently. Order is unspecified and shouldn’t matter.


When to write your own subscriber

Built-in stacks cover the common shapes. Roll your own when:

The events are the public API. Treat them as such.