Aller au contenu principal
Retour au blog

OpenTelemetry + Symfony 7.2 : traces, métriques, logs sans overhead

Flavien Métivier20 mai 202512 min

En production PHP, l'observabilité se résume trop souvent à des logs Monolog dumpés dans un fichier rotatif et à un APM propriétaire — Datadog, New Relic — qui facture à la trace ingérée et t'enferme dans son format. OpenTelemetry change l'équation. Depuis la sortie stable du SDK PHP 1.x en 2024, l'instrumentation vendor-neutral est prête pour la production : aucun agent propriétaire, aucun format maison, un pipeline de données qui s'adapte à n'importe quel backend (Jaeger, Grafana Tempo, Honeycomb, SigNoz). Symfony 7.2, sorti en novembre 2024 et suffisamment stable pour la production depuis le printemps 2025, expose des hooks d'instrumentation granulaires pour intégrer OTel proprement — sans modifier la logique applicative ni introduire une dépendance propriétaire dans ton code métier. Ce guide configure le stack complet sur une app PHP 8.4 / Symfony 7.2 : traces distribuées HTTP, métriques métier, corrélation logs/traces.

Symfony 7.2 et OTel : pourquoi ce moment précis

Deux conditions ont longtemps manqué pour adopter OTel sur Symfony sans friction. La première : un SDK PHP stable. L'écosystème OTel a mis du temps à mûrir côté PHP comparé à Java ou Go. Le SDK PHP 1.x a comblé ce retard en 2024 : API figée, exporters maintenus, conformité aux specs OTLP v1. La deuxième : des points d'entrée fiables dans le framework. Symfony 7.2 apporte trois améliorations concrètes. D'abord, le lifecycle de la requête HTTP via KernelEvents est plus granulaire — kernel.request expose la route résolue dès après le routing, ce qui permet d'ouvrir un span root nommé correctement avant tout autre traitement. Ensuite, l'attribut #[AsEventListener] fonctionne pleinement sur des listeners à priorité haute, sans configuration YAML explicite. Enfin, le HttpClient Symfony 7.2 supporte l'injection de headers arbitraires via ses options, simplifiant la propagation du contexte W3C vers les APIs externes. La combinaison est propre : SDK stable, framework instrumentable, backends open-source matures — et zéro obligation de compte chez un vendor.

Installer le SDK OTel PHP 1.x : cinq paquets, zéro magie

Cinq paquets couvrent les trois piliers de l'observabilité (traces, métriques, logs côté SDK) avec OTel PHP. Le paquet open-telemetry/exporter-otlp provient du dépôt contrib de l'écosystème OTel PHP — distinct du SDK core — et fournit le transport OTLP HTTP/protobuf.

composer require \
  open-telemetry/api:^1.0 \
  open-telemetry/sdk:^1.0 \
  open-telemetry/exporter-otlp:^1.0 \
  open-telemetry/sem-conv:^1.24 \
  php-http/guzzle7-adapter:^1.0

Le SDK PHP lit nativement les variables d'environnement OTEL_* standardisées, ce qui évite d'instancier les providers manuellement pour les cas simples. En production Symfony, expose-les via ton .env.prod.local ou les secrets de ton orchestrateur.

# .env (valeurs par défaut, override en prod via .env.prod.local)
OTEL_SERVICE_NAME=mon-api-symfony
OTEL_SERVICE_VERSION=2.1.0
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# 10 % du trafic en prod : ajuste selon le volume et le coût de stockage
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
# Métriques exportées toutes les 60 secondes
OTEL_METRIC_EXPORT_INTERVAL=60000

Bootstrapper le SDK dans un service Symfony

L'auto-configuration via env vars suffit pour commencer, mais le bootstrap explicite donne le contrôle sur le sampler, la resource et le MeterProvider — indispensable pour configurer un taux d'échantillonnage par environnement ou ajouter des attributs de resource personnalisés (région, instance ID).

<?php
// src/Observability/OtelBootstrap.php
declare(strict_types=1);

namespace App\Observability;

use OpenTelemetry\Context\Propagation\TraceContextPropagator;
use OpenTelemetry\Contrib\Otlp\MetricExporterFactory;
use OpenTelemetry\Contrib\Otlp\SpanExporterFactory;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SDK\Metrics\MeterProvider;
use OpenTelemetry\SDK\Metrics\MetricReader\ExportingMetricReader;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Sdk;
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
use OpenTelemetry\SDK\Trace\Sampler\TraceIdRatioBasedSampler;
use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SemConv\ResourceAttributes;

final class OtelBootstrap
{
    public static function boot(
        string $serviceName,
        string $serviceVersion,
        string $env,
        float  $samplingRatio = 0.1,
    ): void {
        $resource = ResourceInfo::create(Attributes::create([
            ResourceAttributes::SERVICE_NAME           => $serviceName,
            ResourceAttributes::SERVICE_VERSION        => $serviceVersion,
            ResourceAttributes::DEPLOYMENT_ENVIRONMENT => $env,
        ]));

        // Traces — exporter lit OTEL_EXPORTER_OTLP_ENDPOINT depuis l'env
        $spanExporter   = (new SpanExporterFactory())->create();
        $spanProcessor  = BatchSpanProcessor::builder($spanExporter)->build();

        $tracerProvider = TracerProvider::builder()
            ->setResource($resource)
            ->setSampler(new ParentBased(new TraceIdRatioBasedSampler($samplingRatio)))
            ->addSpanProcessor($spanProcessor)
            ->build();

        // Métriques — même endpoint OTLP, chemin /v1/metrics
        $metricExporter = (new MetricExporterFactory())->create();
        $metricReader   = new ExportingMetricReader($metricExporter);

        $meterProvider  = MeterProvider::builder()
            ->setResource($resource)
            ->addReader($metricReader)
            ->build();

        Sdk::builder()
            ->setTracerProvider($tracerProvider)
            ->setMeterProvider($meterProvider)
            ->setPropagator(TraceContextPropagator::getInstance())
            ->buildAndRegisterGlobal();
    }
}

Appelle OtelBootstrap::boot() une seule fois par process — depuis public/index.php avant le boot du kernel, ou dans un CompilerPass. Le BatchSpanProcessor est critique en production : il bufferise les spans en mémoire et exporte par lot de façon asynchrone. Avec ses valeurs par défaut (512 spans en buffer, export toutes les 5 s), l'impact sur le temps de réponse est sous la milliseconde. Évite à tout prix SimpleSpanProcessor en prod : il fait un appel réseau bloquant par span créé.

Traçage distribué HTTP avec propagation W3C TraceContext

La propagation de contexte est le cœur du traçage distribué : sans elle, les spans de ton app Symfony et ceux de tes microservices sont des îles isolées, impossibles à relier dans Tempo ou Jaeger. Le format W3C TraceContext (RFC 7230) standardise deux headers HTTP : traceparent porte le trace_id, le span_id et les flags de sampling ; tracestate transporte des métadonnées propriétaires optionnelles. Côté serveur, l'extraction se fait dans un event listener sur KernelEvents::REQUEST. Si la requête entrante porte un traceparent valide — depuis un service upstream instrumenté, ou depuis le navigateur via OpenTelemetry JS — ton span s'attache automatiquement à la trace parente existante.

<?php
// src/Observability/TraceSubscriber.php
declare(strict_types=1);

namespace App\Observability;

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
use OpenTelemetry\Context\ScopeInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final class TraceSubscriber
{
    private ?\OpenTelemetry\API\Trace\SpanInterface $span = null;
    private ?ScopeInterface $scope = null;

    #[AsEventListener(event: KernelEvents::REQUEST, priority: 100)]
    public function onRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request    = $event->getRequest();
        $tracer     = Globals::tracerProvider()->getTracer('app.symfony', '1.0.0');
        $propagator = Globals::propagator();

        // Extraction du contexte W3C TraceContext depuis les headers entrants
        $parentCtx = $propagator->extract(
            $request->headers->all(),
            ArrayAccessGetterSetter::getInstance(),
            Context::getCurrent(),
        );

        $route    = $request->attributes->get('_route', 'unknown');
        $spanName = sprintf('%s %s', $request->getMethod(), $route);

        $this->span = $tracer
            ->spanBuilder($spanName)
            ->setSpanKind(SpanKind::KIND_SERVER)
            ->setParent($parentCtx)
            ->startSpan();

        $this->span->setAttribute('http.method',      $request->getMethod());
        $this->span->setAttribute('http.url',         $request->getUri());
        $this->span->setAttribute('http.route',       $route);
        $this->span->setAttribute('net.peer.ip',      $request->getClientIp() ?? '');

        $this->scope = $this->span->activate();
    }

    #[AsEventListener(event: KernelEvents::RESPONSE, priority: -100)]
    public function onResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest() || $this->span === null) {
            return;
        }

        $this->span->setAttribute('http.status_code', $event->getResponse()->getStatusCode());
        $this->scope?->detach();
        $this->span->end();

        $this->span  = null;
        $this->scope = null;
    }

    #[AsEventListener(event: KernelEvents::EXCEPTION, priority: 100)]
    public function onException(ExceptionEvent $event): void
    {
        if ($this->span === null) {
            return;
        }

        $this->span->recordException($event->getThrowable());
        $this->span->setStatus(StatusCode::STATUS_ERROR, $event->getThrowable()->getMessage());
    }
}

La propagation sortante — vers les APIs externes appelées depuis Symfony — se fait en injectant les headers W3C dans le HttpClient avant l'appel. Le propagateur écrit automatiquement le traceparent en lisant le span actif dans le contexte courant.

<?php
// Dans n'importe quel service qui appelle une API externe
use OpenTelemetry\API\Globals;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;

// Injection du contexte W3C dans les headers sortants
$headers = [];
Globals::propagator()->inject(
    $headers,
    ArrayAccessGetterSetter::getInstance(),
    Context::getCurrent(),
);

// Le header traceparent est maintenant dans $headers
$response = $this->httpClient->request('POST', 'https://api.partenaire.io/orders', [
    'headers' => $headers,
    'json'    => $payload,
]);

Métriques métier : Counter et Histogram sans couche dédiée

Les traces répondent à « qu'est-ce qui s'est passé sur cette requête précise ». Les métriques répondent à « combien de commandes créées cette heure, quel est le panier médian, combien d'erreurs de paiement par provider ». L'API OTel propose trois instruments principaux : Counter (valeur strictement croissante), Histogram (distribution de valeurs — parfait pour des montants ou des durées), ObservableGauge (valeur mesurée à l'instant T — taille de file, connexions actives). Voici un service de métriques métier complet pour un module de commandes.

Besoin d'un expert Symfony ?

Réserver un appel
<?php
// src/Observability/OrderMetrics.php
declare(strict_types=1);

namespace App\Observability;

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Metrics\CounterInterface;
use OpenTelemetry\API\Metrics\HistogramInterface;

final class OrderMetrics
{
    private CounterInterface   $ordersCreated;
    private HistogramInterface $orderValueEur;
    private CounterInterface   $paymentErrors;
    private HistogramInterface $checkoutDuration;

    public function __construct()
    {
        $meter = Globals::meterProvider()->getMeter('app.orders', '1.0.0');

        $this->ordersCreated = $meter->createCounter(
            name:        'orders.created.total',
            unit:        '{order}',
            description: 'Total number of orders successfully created',
        );

        $this->orderValueEur = $meter->createHistogram(
            name:        'orders.value.eur',
            unit:        'EUR',
            description: 'Monetary value of created orders in EUR',
        );

        $this->paymentErrors = $meter->createCounter(
            name:        'orders.payment_errors.total',
            unit:        '{error}',
            description: 'Total payment errors, labelled by provider and error code',
        );

        $this->checkoutDuration = $meter->createHistogram(
            name:        'checkout.duration.seconds',
            unit:        's',
            description: 'Full checkout pipeline duration in seconds',
        );
    }

    public function recordOrderCreated(float $valueEur, string $channel): void
    {
        $attrs = ['channel' => $channel];
        $this->ordersCreated->add(1, $attrs);
        $this->orderValueEur->record($valueEur, $attrs);
    }

    public function recordPaymentError(string $provider, string $errorCode): void
    {
        $this->paymentErrors->add(1, [
            'payment.provider'   => $provider,
            'payment.error_code' => $errorCode,
        ]);
    }

    public function recordCheckoutDuration(float $seconds, string $channel): void
    {
        $this->checkoutDuration->record($seconds, ['channel' => $channel]);
    }
}

Déclare OrderMetrics comme service Symfony (autowiring suffit) et injecte-le dans le handler ou le service de commandes. L'enregistrement d'une commande devient deux appels : $this->metrics->recordOrderCreated($order->totalEur(), $order->channel()) et, à la fin du tunnel de paiement, $this->metrics->recordCheckoutDuration($elapsed, $channel). Chaque label (channel, payment.provider) devient une dimension interrogeable dans Grafana — sans changer une ligne de logique métier.

Corrélation logs/traces : injecter le trace_id dans Monolog

Traces et logs restent des silos tant que chaque entrée de log n'embarque pas le trace_id et le span_id courants. Grafana Loki, Datadog Logs et la plupart des backends comprennent ces champs et permettent de sauter directement de la ligne de log au span correspondant dans Tempo ou Jaeger. La solution propre sous Symfony : un processor Monolog qui lit le span actif depuis le contexte OTel et l'injecte dans le champ extra de chaque enregistrement — sans aucun couplage dans les services métier.

<?php
// src/Observability/TraceContextProcessor.php
declare(strict_types=1);

namespace App\Observability;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use OpenTelemetry\API\Trace\Span;

final class TraceContextProcessor implements ProcessorInterface
{
    public function __invoke(LogRecord $record): LogRecord
    {
        $span    = Span::getCurrent();
        $context = $span->getContext();

        if (!$context->isValid()) {
            // Pas de span actif — log hors requête ou avant le bootstrap OTel
            return $record;
        }

        return $record->with(extra: array_merge($record->extra, [
            'trace_id' => $context->getTraceId(),
            'span_id'  => $context->getSpanId(),
        ]));
    }
}
# config/packages/monolog.yaml
monolog:
    handlers:
        main:
            type:       stream
            path:       '%kernel.logs_dir%/%kernel.environment%.log'
            level:      info
            processors:
                - App\Observability\TraceContextProcessor
        # En prod : remplace stream par un handler JSON vers stdout
        # pour que le collector puisse parser trace_id et span_id
        # comme champs structurés.

En production, couple ce processor à un formatter JSON (Monolog\Formatter\JsonFormatter) et exporte les logs vers stdout. Le collector OTel ou Promtail les envoie vers Loki. Résultat : dans Grafana, un clic sur « Traces » depuis une ligne de log ouvre directement le span correspondant — sans copier-coller de trace_id à la main.

OTel Collector : le pipeline entre ton app et tes backends

Le collector OTel est le composant qui reçoit les données de ton app, les transforme si besoin (batch, filtre, enrichissement) et les fan-oute vers un ou plusieurs backends. Il découple l'app des backends : changer de Jaeger à Tempo ne modifie pas une ligne de code PHP. En développement, un conteneur suffit.

# docker-compose.override.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.101.0
    command: ["--config=/etc/otel/collector.yaml"]
    volumes:
      - ./docker/otel/collector.yaml:/etc/otel/collector.yaml:ro
    ports:
      - "127.0.0.1:4318:4318"  # OTLP HTTP — non exposé en public

  tempo:
    image: grafana/tempo:2.4.0
    command: ["-config.file=/etc/tempo.yaml"]
    volumes:
      - ./docker/tempo/tempo.yaml:/etc/tempo.yaml:ro
    ports:
      - "127.0.0.1:3200:3200"

---
# docker/otel/collector.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 512

exporters:
  otlp/tempo:
    endpoint: http://tempo:4317
    tls:
      insecure: true
  prometheusremotewrite:
    endpoint: http://prometheus:9090/api/v1/write

service:
  pipelines:
    traces:
      receivers:  [otlp]
      processors: [batch]
      exporters:  [otlp/tempo]
    metrics:
      receivers:  [otlp]
      processors: [batch]
      exporters:  [prometheusremotewrite]

Overhead réel en production

La question légitime : est-ce que tout ça coûte quelque chose ? Avec BatchSpanProcessor et un taux d'échantillonnage à 10 %, l'impact est négligeable pour la grande majorité des applications. Les chiffres clés à retenir :

  • Latence par requête instrumentée : < 1 ms ajoutée par le BatchSpanProcessor (l'export est asynchrone, découplé du cycle de vie de la requête HTTP).
  • Requêtes non échantillonnées (90 % avec un ratio de 0.1) : la décision de sampling est prise en tête de requête ; aucun span n'est créé, l'overhead est proche de zéro.
  • Mémoire : ~2–5 Mo pour le SDK + le buffer de spans. Stable dans le temps grâce au buffer borné à 512 spans par défaut.
  • Réseau : l'export OTLP HTTP/protobuf est compressé et batchisé — environ 1 appel réseau toutes les 5 s, indépendant du volume de trafic.
  • Métriques : le MeterProvider exporte toutes les 60 s (configurable via OTEL_METRIC_EXPORT_INTERVAL). L'impact est négligeable en dehors de la fenêtre d'export.

Si tu instrumentes des hot paths très fréquents (boucles à plusieurs centaines d'itérations par requête), crée des spans enfants uniquement pour les opérations qui ont du sens à observer — une requête SQL, un appel réseau — pas pour chaque itération de boucle. Le coût d'un span non exporté (requête non échantillonnée) est une allocation mémoire de quelques octets, immédiatement récupérée.

Ce que tu peux faire avec ce stack

  • Déboguer une lenteur en production : retrouve le span de la requête HTTP, descends dans les spans enfants (SQL, appels API), identifie le goulot d'étranglement en millisecondes.
  • Alerter sur des SLOs métier : un taux d'erreur de paiement > 2 % sur le provider Stripe déclenche une alerte Grafana avant que le support ne remonte l'info.
  • Corréler un log d'erreur et sa trace : depuis Loki, un clic sur trace_id ouvre le span Tempo correspondant — contexte complet sans investigation manuelle.
  • Changer de backend sans toucher au code : remplace Tempo par Honeycomb ou SigNoz en modifiant uniquement la config du collector — le code PHP reste identique.
  • Tracer les frontières de service : les headers W3C propagés vers les APIs partenaires ou les microservices permettent de voir la trace complète, de bout en bout, dans un seul outil.

Le setup décrit dans ce guide est suffisant pour instrumenter une application Symfony 7.2 de taille moyenne en moins d'une demi-journée. Si ton app grossit ou que tu veux explorer des patterns plus avancés — sampling par route, enrichissement de spans depuis un middleware, intégration avec RabbitMQ ou Kafka — les bases sont posées : le SDK est stable, les points d'extension sont là, et tu ne dépends d'aucun vendor pour les faire évoluer. Tu veux un audit de ton infrastructure d'observabilité ou de la qualité de ton code PHP avant d'industrialiser ce type de setup ? Discutons-en.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.