APM for PHP Symfony and Messenger Workers: 2026 Guide
Trace Symfony HTTP requests and Messenger workers with OpenTelemetry APM. Doctrine spans, queue timing, full visibility.
Last month I spent an entire Wednesday afternoon hunting a bug that only appeared in production. Users complained that checkout was "slow sometimes" — not always, just sometimes. Logs showed nothing. Sentry showed no exceptions. The Messenger workers were processing jobs, supervisorctl said everything was healthy, and I had zero visibility into what was actually happening inside those async handlers. If you're weighing your options, our comparison of error tracking vs logging vs APM breaks down when each approach makes sense.
Turns out one worker was making a Doctrine query that occasionally hit a 4-second database lock. But I didn't know that until I'd added APM and watched the spans light up like a Christmas tree of regret.
This tutorial walks through instrumenting a Symfony application with APM that covers both HTTP kernel requests and Messenger async workers. We'll trace Doctrine queries as individual spans, capture queue job timing, and wire it all into JustAnalytics using OpenTelemetry. By the end? "Slow sometimes" becomes a solvable problem instead of a Wednesday-afternoon mystery.
What you'll have by the end
A Symfony 6 or 7 application with:
- HTTP kernel tracing that creates spans for every controller action
- Doctrine query spans showing SQL execution time and parameters
- Messenger middleware that traces async job processing — queue name, handler duration, the works
- All traces flowing to JustAnalytics APM for P95/P99 latency dashboards and service maps
- Error correlation so exceptions link back to their parent trace (this is the part I wish I'd had two years ago)
If you've been paying $99/month for Tideways or fighting with Datadog's PHP agent (and losing), this setup consolidates your APM into the same platform as your error tracking and analytics — JustAnalytics Pro at $49/month ($39/month annual) with 1M events/month across up to 5 sites. We break down the true cost of observability stacks in a separate analysis if you want the numbers.
Prerequisites
- PHP 8.2+ and Symfony 6.4 or 7.x (tested on Symfony 7.1)
- Composer for package management
- A JustAnalytics account — free tier works for testing (100K events/month)
- Symfony Messenger configured with any transport (Redis, RabbitMQ, Doctrine, or sync)
- Doctrine ORM — the query tracing assumes you're using Doctrine, though the pattern adapts to other ORMs
Already running Laravel? We have a parallel tutorial for Laravel APM that covers the same pattern with Eloquent and Horizon.
Step 1: Install OpenTelemetry SDK and dependencies
Symfony doesn't ship with OpenTelemetry out of the box. (There's been talk. There's always talk.) So we'll bring in the SDK and the HTTP/gRPC exporters:
composer require open-telemetry/sdk \
open-telemetry/exporter-otlp \
open-telemetry/transport-grpc \
symfony/http-client
The open-telemetry/sdk package is about 200KB and adds minimal overhead. The exporter uses gRPC by default, which batches spans efficiently.
Add your JustAnalytics OTLP endpoint credentials to .env:
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.justanalytics.app
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer your-api-key-here
OTEL_SERVICE_NAME=my-symfony-app
Grab your API key from the JustAnalytics dashboard under Settings → API Keys. The OTLP endpoint accepts standard OpenTelemetry Protocol, so if you ever need to switch vendors, you change one URL. No lock-in. I appreciate that about OTEL even when I'm cursing at the documentation.
Step 2: Create the tracer service
Build a service that initializes the OpenTelemetry tracer and provides helper methods for creating spans. Create src/Service/Tracing/TracerService.php:
<?php
namespace App\Service\Tracing;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\TracerInterface;
use OpenTelemetry\Context\Context;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\SDK\Common\Export\TransportFactoryInterface;
class TracerService
{
private TracerInterface $tracer;
private ?SpanInterface $rootSpan = null;
public function __construct(
private string $serviceName,
private string $otlpEndpoint,
private string $otlpHeaders
) {
$this->initializeTracer();
}
private function initializeTracer(): void
{
$headers = [];
foreach (explode(',', $this->otlpHeaders) as $header) {
[$key, $value] = explode('=', $header, 2);
$headers[trim($key)] = trim($value);
}
$exporter = new SpanExporter(
$this->otlpEndpoint . '/v1/traces',
$headers
);
$processor = new BatchSpanProcessor($exporter);
$provider = new TracerProvider($processor);
$this->tracer = $provider->getTracer($this->serviceName, '1.0.0');
}
public function startRootSpan(string $name, array $attributes = []): SpanInterface
{
$this->rootSpan = $this->tracer->spanBuilder($name)
->setSpanKind(SpanKind::KIND_SERVER)
->startSpan();
foreach ($attributes as $key => $value) {
$this->rootSpan->setAttribute($key, $value);
}
return $this->rootSpan;
}
public function startSpan(string $name, array $attributes = []): SpanInterface
{
$span = $this->tracer->spanBuilder($name)
->setSpanKind(SpanKind::KIND_INTERNAL)
->startSpan();
foreach ($attributes as $key => $value) {
$span->setAttribute($key, $value);
}
return $span;
}
public function getRootSpan(): ?SpanInterface
{
return $this->rootSpan;
}
}
Wire it up in config/services.yaml:
services:
App\Service\Tracing\TracerService:
arguments:
$serviceName: '%env(OTEL_SERVICE_NAME)%'
$otlpEndpoint: '%env(OTEL_EXPORTER_OTLP_ENDPOINT)%'
$otlpHeaders: '%env(OTEL_EXPORTER_OTLP_HEADERS)%'
The BatchSpanProcessor queues spans and exports them in batches — you won't see an HTTP call on every span creation. This is critical for production performance.
Step 3: Add HTTP kernel subscriber for request tracing
Create an event subscriber that wraps every HTTP request in a root span. This gives you visibility into controller latency across your entire application. Create src/EventSubscriber/TracingSubscriber.php:
<?php
namespace App\EventSubscriber;
use App\Service\Tracing\TracerService;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\StatusCode;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class TracingSubscriber implements EventSubscriberInterface
{
private ?SpanInterface $requestSpan = null;
public function __construct(
private TracerService $tracer
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 256],
KernelEvents::RESPONSE => ['onKernelResponse', -256],
KernelEvents::EXCEPTION => ['onKernelException', 0],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$route = $request->attributes->get('_route', 'unknown');
$this->requestSpan = $this->tracer->startRootSpan(
sprintf('%s %s', $request->getMethod(), $route),
[
'http.method' => $request->getMethod(),
'http.url' => $request->getUri(),
'http.route' => $route,
'http.user_agent' => substr($request->headers->get('User-Agent', ''), 0, 200),
]
);
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest() || !$this->requestSpan) {
return;
}
$this->requestSpan->setAttribute('http.status_code', $event->getResponse()->getStatusCode());
$this->requestSpan->end();
$this->requestSpan = null;
}
public function onKernelException(ExceptionEvent $event): void
{
if (!$this->requestSpan) {
return;
}
$exception = $event->getThrowable();
$this->requestSpan->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
$this->requestSpan->recordException($exception);
}
}
The priority numbers matter here. 256 on request means we start tracing before security, routing, and controller resolution run. -256 on response means we end the span after everything else finishes. Your entire request lifecycle is now visible.
I got these priorities wrong the first time and spent an hour wondering why my spans were missing auth time. Don't be me.
Run a test request and check your JustAnalytics APM dashboard — you should see the span appear within 10-15 seconds (the batch processor flushes periodically).
Step 4: Trace Doctrine queries
Doctrine queries are usually the slowest part of a Symfony request. Usually. Sometimes it's that one Redis call you forgot about — but Doctrine is a good bet.
We want individual spans for each query. The cleanest approach is a custom DBAL middleware. Create src/Doctrine/TracingMiddleware.php:
<?php
namespace App\Doctrine;
use App\Service\Tracing\TracerService;
use Doctrine\DBAL\Driver\Middleware\AbstractStatementMiddleware;
use Doctrine\DBAL\Driver\Result;
use Doctrine\DBAL\Driver\Statement;
use Doctrine\DBAL\ParameterType;
class TracingStatement extends AbstractStatementMiddleware
{
public function __construct(
Statement $statement,
private TracerService $tracer,
private string $sql
) {
parent::__construct($statement);
}
public function execute(): Result
{
$span = $this->tracer->startSpan('doctrine.query', [
'db.system' => 'mysql',
'db.statement' => substr($this->sql, 0, 1000),
]);
try {
$result = parent::execute();
$span->end();
return $result;
} catch (\Throwable $e) {
$span->recordException($e);
$span->end();
throw $e;
}
}
}
And the connection wrapper in src/Doctrine/TracingConnection.php:
<?php
namespace App\Doctrine;
use App\Service\Tracing\TracerService;
use Doctrine\DBAL\Driver\Connection;
use Doctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware;
use Doctrine\DBAL\Driver\Statement;
class TracingConnection extends AbstractConnectionMiddleware
{
public function __construct(
Connection $connection,
private TracerService $tracer
) {
parent::__construct($connection);
}
public function prepare(string $sql): Statement
{
return new TracingStatement(
parent::prepare($sql),
$this->tracer,
$sql
);
}
}
Finally, the middleware itself in src/Doctrine/TracingDbalMiddleware.php:
<?php
namespace App\Doctrine;
use App\Service\Tracing\TracerService;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Middleware;
class TracingDbalMiddleware implements Middleware
{
public function __construct(
private TracerService $tracer
) {}
public function wrap(Driver $driver): Driver
{
return new TracingDriver($driver, $this->tracer);
}
}
Register it in config/packages/doctrine.yaml:
doctrine:
dbal:
middlewares:
- App\Doctrine\TracingDbalMiddleware
Now every Doctrine query gets its own span nested under the parent request span. You'll see exactly which queries are slow, how many queries per request, and whether you've got an N+1 problem hiding in your API. For finding P99 latency sources in slow API endpoints, this visibility is essential.
I found a 7-query-per-row fetch loop this way on my first day with tracing enabled. It had been running for six months. Nobody noticed because the page still loaded in under 2 seconds. We were just burning database connections for no reason.
Step 5: Messenger middleware for async workers
Messenger workers run outside the HTTP kernel, so we need separate instrumentation. Create a bus middleware that wraps every handled message. Create src/Messenger/TracingMiddleware.php:
<?php
namespace App\Messenger;
use App\Service\Tracing\TracerService;
use OpenTelemetry\API\Trace\StatusCode;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
class TracingMiddleware implements MiddlewareInterface
{
public function __construct(
private TracerService $tracer
) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$message = $envelope->getMessage();
$messageClass = get_class($message);
$shortName = substr($messageClass, strrpos($messageClass, '\\') + 1);
$receivedStamp = $envelope->last(ReceivedStamp::class);
$transportName = $receivedStamp?->getTransportName() ?? 'sync';
$span = $this->tracer->startRootSpan(
sprintf('messenger.handle %s', $shortName),
[
'messaging.system' => 'symfony_messenger',
'messaging.destination' => $transportName,
'messaging.message_type' => $messageClass,
]
);
try {
$result = $stack->next()->handle($envelope, $stack);
$span->end();
return $result;
} catch (\Throwable $e) {
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
$span->recordException($e);
$span->end();
throw $e;
}
}
}
Register it in config/packages/messenger.yaml:
framework:
messenger:
buses:
messenger.bus.default:
middleware:
- App\Messenger\TracingMiddleware
Now your async workers show up in APM. You can see which handlers are slow, which transports are backing up, and correlate worker errors with specific message types. The ReceivedStamp check distinguishes between dispatched messages (which run sync or get queued) and consumed messages (which are being processed by a worker).
Common errors and how to fix them
"Class OpenTelemetry\SDK\Trace\TracerProvider not found" in production. Ah, this one. The OpenTelemetry packages require the protobuf and grpc PHP extensions for the OTLP exporter. Check php -m | grep grpc and php -m | grep protobuf. If missing, install them via PECL or your package manager. On Alpine Docker images: apk add php82-pecl-grpc php82-pecl-protobuf.
Spans appear but have no parent-child relationship. The Context isn't propagating correctly between spans. Make sure you're calling startRootSpan for the outermost span and startSpan for nested ones. The SDK tracks context automatically in a single thread, but if you're using async (Swoole, ReactPHP, etc.), you'll need to pass context explicitly.
Doctrine spans show up but with empty SQL. Some Doctrine configurations strip SQL in production for security. Check your doctrine.yaml for logging: false or similar settings. The tracing middleware captures SQL at the DBAL layer, but if Doctrine's prepared statement rewriting is too aggressive, you might see placeholders only.
Messenger workers don't show traces. The TracingMiddleware needs to be registered in the bus configuration, not as a general service. Check that it's under framework.messenger.buses.messenger.bus.default.middleware. Also verify your worker is actually consuming from the queue — a stopped worker won't generate traces.
Traces are delayed by 30+ seconds. The BatchSpanProcessor has a default export interval. You can tune it by passing options to the processor constructor: new BatchSpanProcessor($exporter, null, 5000, 10000) — third param is max queue size, fourth is export timeout in ms. I run with tighter batching in staging where I want faster feedback. For alerting on production issues, see our guide on routing observability alerts to Slack, Teams, and PagerDuty.
What this setup won't fix
I should be honest about the limits here.
APM won't give you profiler-level detail. If you need CPU flamegraphs, memory allocation tracking, or line-by-line execution time, you still want Blackfire or Tideways. Those are profilers — they sample deeply but not continuously. APM samples broadly but shallowly. Different tools. I use both.
It also won't automatically correlate traces across multiple PHP services unless you propagate trace context in HTTP headers. If you're calling another Symfony service from your app, you'll need to inject the traceparent header manually or use an HTTP client middleware. The OpenTelemetry HTTP instrumentation docs cover this pattern.
And if your Messenger workers are running on separate servers, make sure they're configured with the same OTLP endpoint — otherwise their traces end up in a different project.
Next steps
You've got APM coverage for Symfony's HTTP kernel and Messenger workers with Doctrine query visibility. What now?
- Add custom spans around external API calls (Stripe, payment processors, third-party services) using the same
startSpanpattern - Set up alerting on P95 latency thresholds — our SLO and error budget alerting guide walks through the setup
- Wire error tracking alongside APM so exceptions link to their traces — we covered this in the error correlation guide
If you're running both paid acquisition and Symfony apps, ClickzProtect can flag fraudulent clicks before they waste your ad budget. And for teams managing transactional email alongside their Symfony stack, JustEmails tracks deliverability metrics that complement your APM data.
The full code from this tutorial is MIT licensed and sitting in a GitHub Gist. Fork it, break it, adapt it to your weird Messenger transport setup that I definitely didn't anticipate. (If you're using AMQP with dead letter exchanges, I'd actually love to hear how this holds up.)
Frequently Asked Questions
Does this work with Symfony Messenger's different transports like RabbitMQ and Amazon SQS?
Yes. The middleware approach hooks into the message bus itself, not the transport layer. Whether you're running Redis, RabbitMQ, Amazon SQS, or even the sync transport for testing, the spans get created the same way. The transport name shows up in the span attributes so you can filter by queue type in your APM dashboard.
How does this compare to Blackfire or Tideways for Symfony profiling?
Blackfire and Tideways are excellent for deep profiling — call graphs, memory allocation, CPU flamegraphs. But they're profilers, not APM. You don't run them continuously in production at 100% sampling. JustAnalytics APM is designed for always-on tracing: P95/P99 latency across all requests, service maps, error correlation with funnel analytics. Use Blackfire for debugging specific slow endpoints. Use APM for understanding production behavior over time.
Will adding OpenTelemetry spans slow down my Symfony app?
Measurable but small. In benchmarks on a Symfony 7 app handling 800 requests/second, adding span instrumentation increased p99 latency by 3-4ms. The OpenTelemetry SDK batches exports, so you're not making an HTTP call per span. If you're chasing sub-5ms response times, you'll notice it. For typical web apps where responses are 50-200ms? Noise.
Can I trace across multiple Symfony services in a microservices setup?
Yes — that's where distributed tracing really shines. Pass the trace context in HTTP headers (W3C Trace Context or B3 format) and child services will attach their spans to the same trace. JustAnalytics shows the full service map with latency breakdowns per hop. We cover cross-service propagation in the advanced tracing guide linked in the next steps section.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.