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:
- registry summary — total registered methods, MCP-exposed tools, streaming, deprecated, role-protected, cached, and rate-limited counts (from compile-time metadata; visible even when the request did not call any RPC method)
- this request — call count, unique methods, total duration, errors, cache hits
- one row per invocation: method name, handler class, normalized params, status, duration, result preview (or exception on failures)
- batch dispatches — fan-out decision, batch size, sub-call timings (when parallel batch ran)
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:
- a
rpc.method=user.updatetag — filter and group by method; - breadcrumbs with the last RPC calls before the crash;
- if
transactions: true, a child span under the active transaction (op: rpc.call,description: <method-name>).
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
- Span per call with OTel RPC semantic conventions:
rpc.system=jsonrpc,rpc.method=user.update,rpc.jsonrpc.version=2.0, plusrpc.jsonrpc.error_code/rpc.jsonrpc.error_messageon failure. - Metrics
rpc.server.duration(histogram, ms) andrpc.server.requests(counter) — both labelled byrpc.methodandoutcome(ok/error). Standard dashboards work without further setup. - Stream methods — span stays open from dispatch until the iterator
drains, carrying
rpc.stream.row_countas a final attribute. Per-row spans available behindspan_per_row: truewhen debugging row-level latency. - Distributed trace —
traceparentfrom the incoming HTTP request becomes the parent of the RPC span, so a trace started in a mobile client / API gateway continues seamlessly through your service.
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:
- Logging: ERROR-and-up to stdout for the platform log aggregator
- Sentry: exception breadcrumbs + tags
- OpenTelemetry: traces and metrics to a Tempo/Mimir backend
- Profiler: off in prod, on in dev via
kernel.debug
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:
- you need to redact specific param fields before they hit any backend;
- you need to sample (1% of
feed.list, 100% ofpayments.charge); - you need to enrich with information only your app knows (tenant id, feature flag bucket, A/B segment);
- you have a backend none of the built-ins support (Datadog StatsD direct, Graphite, Honeycomb-without-OTel-collector, …).
The events are the public API. Treat them as such.