Distributed Tracing
A trace records one request as a tree of timed spans, so a slow page tells you which part was slow.
$tracer->spanBuilder(string $spanName)->startSpan(): SpanInterfaceA span is one timed piece of work: a name, a start, an end, some attributes, and a parent. A trace is all the spans belonging to one request, linked into a tree by those parents. Drawn as nested bars, it is a waterfall: the request at the top, the database queries and outbound HTTP calls nested underneath, each bar as wide as the time it took.
Metrics tell you requests are slow. A trace tells you where. The waterfall answers two questions at a glance: which bar is widest, and are there many identical narrow bars - which is the N+1 query problem, one query per row of a loop.
How it gets collected
Symfony 8.1 has no tracing component. The standard is OpenTelemetry: a vendor-neutral specification with a PHP implementation. Three pieces are involved. The opentelemetry PECL extension, which lets libraries be instrumented without editing their code. The SDK and an exporter, open-telemetry/sdk and open-telemetry/exporter-otlp, which batch spans and send them to a collector. And auto-instrumentation packages: open-telemetry/opentelemetry-auto-symfony creates the root span on HttpKernel::handle, spans for HttpClient requests, and spans for the Messenger bus and its transports. Doctrine issues its queries through PDO, so database spans come from open-telemetry/opentelemetry-auto-pdo.
Your own spans
Auto-instrumentation knows about the framework, not about your domain. Wrap work worth naming in a manual span. Setting several attributes has a shorthand: setAttributes(['a' => 1, 'b' => 2]) is the same as two chained setAttribute() calls.
Example
// src/Pricing/QuoteBuilder.php
declare(strict_types=1);
namespace App\Pricing;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\API\Trace\TracerInterface;
final class QuoteBuilder
{
public function __construct(
private readonly TracerInterface $tracer,
private readonly RateTable $rates,
) {
}
public function build(string $sku, int $quantity): Quote
{
$span = $this->tracer->spanBuilder('pricing.build_quote')
->setSpanKind(SpanKind::KIND_INTERNAL)
->setAttributes(['pricing.sku' => $sku, 'pricing.quantity' => $quantity])
->startSpan();
// While the scope is attached, any span started below this line
// becomes a child of this one, including the database spans.
$scope = $span->activate();
try {
return $this->rates->quote($sku, $quantity);
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$scope->detach();
$span->end();
}
}
}When to use it
- Find out whether a slow endpoint is spending its time in the database or in an outbound API call.
- Spot one query repeated per row in a listing page from the shape of the waterfall.
- Follow a message from the controller that dispatched it to the worker that handled it.
- Show that an upstream provider, not your code, caused last night's latency.
More examples
Making the tracer injectable
OpenTelemetry's PHP packages are plain Composer libraries, not a Symfony bundle, so nothing registers them as services for you. These two definitions make TracerInterface a type you can ask for in a constructor like any other dependency. Fetching it with Globals::tracerProvider() inside a class would work too, but then the class reaches out for its own collaborators and cannot be tested without the SDK present.
# config/services.yaml
services:
# Globals holds whatever the autoloaded SDK registered at startup.
OpenTelemetry\API\Trace\TracerProviderInterface:
factory: ['OpenTelemetry\API\Globals', 'tracerProvider']
# getTracer($name) names the instrumentation, not the service.
OpenTelemetry\API\Trace\TracerInterface:
factory: ['@OpenTelemetry\API\Trace\TracerProviderInterface', 'getTracer']
arguments: ['app']Turning it on, per environment
The extension and the packages can be installed in the image while collection stays off, which is what you want in dev and test: set OTEL_PHP_AUTOLOAD_ENABLED only where a collector exists. Sampling is the setting people forget. At ten percent, the one slow request a user reported is probably not in your trace store, so pair tracing with metrics rather than treating it as a complete record.
# .env.prod
# Nothing is collected until the SDK autoload is switched on.
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=storefront
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=none
OTEL_LOGS_EXPORTER=none
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# Keep one request in ten. Child services follow the parent's decision,
# so a sampled trace stays whole across service boundaries.
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
# The scraper hits /metrics every few seconds; those are not interesting traces.
OTEL_PHP_EXCLUDED_URLS=/metrics,/health
# Switch off one instrumentation without removing the package.
# OTEL_PHP_DISABLED_INSTRUMENTATIONS=symfonyAdding detail to a span you did not create
Most of the time you do not need a new span, only a better one. Attributes added here appear on the request's root span, which is what you filter on when searching for traces: "show me requests for article 4193 that took over a second". GET /articles/7 returns 200 with {"id":7}; the tracing calls change nothing about the response. The isRecording() check matters when gathering an attribute would itself cost a query.
// src/Controller/ArticleController.php
declare(strict_types=1);
namespace App\Controller;
use OpenTelemetry\API\Trace\Span;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ArticleController
{
#[Route('/articles/{id}', name: 'app_article_show', methods: ['GET'])]
public function show(int $id): Response
{
// The root span already exists, created by the Symfony
// instrumentation. getCurrent() returns it rather than a new one.
$span = Span::getCurrent();
$span->setAttribute('app.article_id', $id);
if (!$span->isRecording()) {
// This request was not sampled; skip work done only for tracing.
return new JsonResponse(['id' => $id]);
}
$span->addEvent('article.cache_miss');
return new JsonResponse(['id' => $id]);
}
}
Discussion