Distributed Tracing

A trace records one request as a tree of timed spans, so a slow page tells you which part was slow.

Syntax$tracer->spanBuilder(string $spanName)->startSpan(): SpanInterface

A 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

Example · php
// 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.

Example · yaml
# 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.

Example · dotenv
# .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=symfony

Adding 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.

Example · php
// 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

  • Be the first to comment on this lesson.