OpenTelemetry
Symfony
Quick Start

Symfony

Instrument a Symfony application with OpenTelemetry and export traces, metrics, and logs to TracePath over OTLP/HTTP. Everything on this page uses published OpenTelemetry PHP packages — there is no TracePath package to install.

A first-party TracePath Symfony bundle is not published. A bundle would remove the PECL extension requirement below and set endpoint names correctly on its own. It does not exist today, so do not follow an older guide that tells you to composer require one. The path on this page is vendor-neutral and works now. Mail [email protected] if the extension requirement blocks you.

Prerequisites

  • PHP 8.1 or newer.
  • Symfony 6.4 LTS, 7.x, or 8.x.
  • Composer.
  • The opentelemetry PECL extension. Every open-telemetry/opentelemetry-auto-* package hooks your framework through this extension, so zero-code instrumentation does not work without it. On a managed host where you cannot install extensions, skip to Without the PECL extension.
  • A TracePath project created with framework OpenTelemetry, and its project token.

Step 1: Install the extension

pecl install opentelemetry

Then enable it in your php.ini and restart PHP-FPM:

extension=opentelemetry.so

Confirm it loaded before going further. If this prints nothing, nothing else on this page will produce a single span:

php -m | grep opentelemetry

Step 2: Install the packages

composer require \
    open-telemetry/sdk \
    open-telemetry/exporter-otlp \
    open-telemetry/opentelemetry-auto-symfony \
    php-http/guzzle7-adapter

Four packages, four jobs. open-telemetry/sdk records spans, metrics and log records. open-telemetry/exporter-otlp serializes them as OTLP and php-http/guzzle7-adapter is the HTTP client that actually ships them — without both of those the SDK records telemetry and drops it on the floor. open-telemetry/opentelemetry-auto-symfony is the zero-code instrumentation that creates a span per request, per console command and per Messenger message.

Step 3: Configure the exporter

Add the following to your .env, replacing the token with your project's:

OTEL_SERVICE_NAME=my-symfony-app
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.tracepath.dev/api/otel
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-project-token"

Every project in the dashboard at app.tracepath.dev (opens in a new tab) has a Connection page carrying a ready-made config snippet with that project's token already filled in. Copy from there rather than retyping the endpoint.

OTEL_EXPORTER_OTLP_ENDPOINT is the base URL. The exporter appends /v1/traces, /v1/metrics and /v1/logs itself, which is exactly how TracePath's ingest paths are laid out.

⚠️

http/json, not http/protobuf. Both are accepted by TracePath, but the pure-PHP protobuf encoder is slow enough to notice on a busy app. Stay on http/json unless you install ext-protobuf (pecl install protobuf). grpc is not an option at all: TracePath serves OTLP over HTTP only.

Step 4: Turn the SDK on

Installing the SDK does not start it. It starts only when OTEL_PHP_AUTOLOAD_ENABLED is set, and it reads that during Composer autoload.

🚫

Never put OTEL_PHP_AUTOLOAD_ENABLED in .env. Composer's autoloader runs before Symfony's Dotenv component reads .env, so the variable is always read too late. The SDK stays off, every signal becomes a silent no-op, and nothing anywhere reports an error.

Set it as a real process environment variable, in whichever of these matches your deployment:

; php-fpm pool config
env[OTEL_PHP_AUTOLOAD_ENABLED] = true
ENV OTEL_PHP_AUTOLOAD_ENABLED=true
SetEnv OTEL_PHP_AUTOLOAD_ENABLED true

For a local symfony server:start, export it in your shell before starting the server.

Leave public/index.php alone

You never call SdkAutoloader::autoload() by hand and you never edit the front controller. open-telemetry/sdk registers its _autoload.php as a Composer files autoload entry, so it runs on every request already. The stock Symfony front controller is correct as generated:

<?php
// public/index.php (leave this file exactly as Symfony generated it)
 
use App\Kernel;
 
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
 
return function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

Replacing vendor/autoload_runtime.php with vendor/autoload.php drops symfony/runtime, which is what boots Dotenv. Your .env then never loads, so both the OTEL_* variables and every %env(...)% in your config break. A typical app starts throwing Environment variable not found: "DATABASE_URL".

Step 5: Fix endpoint names (required)

This step is what turns a useless endpoint list into a useful one, so do not skip it.

The upstream Symfony instrumentation sets http.route to the Symfony route name (app_user_show), not to the route path (/users/{id}). TracePath rejects any http.route that does not begin with / and falls back to the concrete URL, so without this step you get one endpoint row per URL — GET /users/41, GET /users/42, GET /users/43 — and per-endpoint P50/P95 and endpoint-scoped notification rules stop meaning anything.

Add one event subscriber that rewrites the attribute to the route path:

<?php
// src/EventSubscriber/TracePathRouteSubscriber.php
namespace App\EventSubscriber;
 
use OpenTelemetry\API\Trace\Span;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
 
final class TracePathRouteSubscriber implements EventSubscriberInterface
{
    /** @var array<string, string>|null route name => path template */
    private ?array $paths = null;
 
    public function __construct(private readonly RouterInterface $router)
    {
    }
 
    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::CONTROLLER => 'onKernelController'];
    }
 
    public function onKernelController(ControllerEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }
 
        $name = $event->getRequest()->attributes->get('_route');
        if (!is_string($name) || $name === '') {
            return;
        }
 
        if ($this->paths === null) {
            $this->paths = [];
            foreach ($this->router->getRouteCollection() as $routeName => $route) {
                $this->paths[$routeName] = $route->getPath();
            }
        }
 
        if (isset($this->paths[$name])) {
            Span::getCurrent()->setAttribute('http.route', $this->paths[$name]);
        }
    }
}

With Symfony's default autoconfiguration the subscriber is registered as soon as the file exists — no service definition to write. The route table is built once per PHP process and reused, so the lookup costs nothing per request.

KernelEvents::CONTROLLER is the right hook: it fires after routing has resolved _route and while the instrumentation's request span is still the active span, so Span::getCurrent() is the span that becomes the endpoint row.

After this, /users/41, /users/42 and /users/43 all land on a single GET /users/{id} row.

Step 6: Verify

Add a route that throws:

<?php
// src/Controller/TestController.php
namespace App\Controller;
 
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
 
class TestController
{
    #[Route('/testing', name: 'testing')]
    public function index(): Response
    {
        throw new \RuntimeException('Test error from TracePath integration');
    }
}

Clear the cache, restart PHP-FPM so it picks up the environment variable, and request /testing. Within a few seconds the dashboard should show:

  • Endpoints: GET /testing with status code 500.
  • Issues: RuntimeException: Test error from TracePath integration with a PHP stack trace pointing at TestController.php.

Both come from the same request. Symfony's error listener turns the throwable into a 500 response and the instrumentation records the exception on the request span, so the endpoint row and the Issue agree.

Nothing arrived

Work down this list in order.

CheckHowIf it fails
The extension is loadedphp -m | grep opentelemetryRe-run Step 1. PHP-FPM and your CLI can load different php.ini files — check both.
The SDK actually startedphp -r 'require "vendor/autoload.php"; var_dump(get_class(\OpenTelemetry\API\Globals::tracerProvider()));' with the env var exportedA NoopTracerProvider means OTEL_PHP_AUTOLOAD_ENABLED never reached Composer autoload. Re-read Step 4 — the usual cause is that it is in .env.
The endpoint and token are goodPost an empty batch with curl, see Nothing Is Showing Up401 is a bad token; 503 is quota or saturation.
The exporter can reach the networkOTEL_PHP_LOG_DESTINATION=stderr and watch the error logPHP's own error log is where the OTLP exporter reports transport failures.

Without the PECL extension

Zero-code instrumentation is the only thing that needs ext-opentelemetry. The SDK, the exporters and the OpenTelemetry API are pure PHP, so on a host where you cannot install extensions you can still send telemetry — you just write the spans yourself.

Install three packages instead of four:

composer require open-telemetry/sdk open-telemetry/exporter-otlp php-http/guzzle7-adapter

Keep Steps 3 and 4 exactly as written, then open the request span in a subscriber. This replaces both the auto-instrumentation and the route fix from Step 5:

<?php
// src/EventSubscriber/TracePathRequestSubscriber.php
namespace App\EventSubscriber;
 
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\Context\ScopeInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
 
final class TracePathRequestSubscriber implements EventSubscriberInterface
{
    private ?SpanInterface $span = null;
    private ?ScopeInterface $scope = null;
 
    public function __construct(private readonly RouterInterface $router)
    {
    }
 
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onRequest', 4096],
            KernelEvents::EXCEPTION => 'onException',
            KernelEvents::TERMINATE => 'onTerminate',
        ];
    }
 
    public function onRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }
 
        $request = $event->getRequest();
        $this->span = Globals::tracerProvider()
            ->getTracer('app')
            ->spanBuilder($request->getMethod())
            ->setSpanKind(SpanKind::KIND_SERVER)
            ->startSpan();
        $this->scope = $this->span->activate();
 
        $this->span->setAttribute('http.request.method', $request->getMethod());
        $this->span->setAttribute('url.path', $request->getPathInfo());
    }
 
    public function onException(ExceptionEvent $event): void
    {
        if ($this->span === null || !$event->isMainRequest()) {
            return;
        }
 
        $throwable = $event->getThrowable();
        $this->span->recordException($throwable);
        $this->span->setStatus(StatusCode::STATUS_ERROR, $throwable->getMessage());
    }
 
    public function onTerminate(TerminateEvent $event): void
    {
        if ($this->span === null) {
            return;
        }
 
        $request = $event->getRequest();
        $name = $request->attributes->get('_route');
        $route = is_string($name) ? $this->router->getRouteCollection()->get($name)?->getPath() : null;
        if ($route !== null) {
            $this->span->setAttribute('http.route', $route);
            $this->span->updateName($request->getMethod().' '.$route);
        }
 
        $this->span->setAttribute('http.response.status_code', $event->getResponse()->getStatusCode());
 
        $this->scope?->detach();
        $this->span->end();
        $this->span = null;
        $this->scope = null;
    }
}

Priority 4096 on KernelEvents::REQUEST puts the listener ahead of Symfony's router, so the span covers routing as well as the controller. The route template is only known after routing, which is why http.route is stamped on at kernel.terminate — the span has not ended yet, so the attribute still lands on it.

This gives you endpoints, status codes and issues. Doctrine queries, Twig renders and outgoing HTTP calls stay uninstrumented, because those are what the extension-based hooks provide. Add child spans by hand where you need them — see Spans.

What gets captured

With the extension-based setup from Steps 1–5:

  • Endpoints: every HTTP request, grouped by route template after Step 5 (GET /users/{id}, not GET /users/42).
  • Status codes: the real response code, including 500s from unhandled exceptions.
  • Exceptions: unhandled errors become Issues with a full PHP stack trace. See Exceptions.
  • Console commands: every bin/console run lands on the Tasks page. See Tasks.
  • Messenger jobs: dispatched and consumed messages. See Tasks.

Unrouted requests — 404s where nothing matched — group under UNMATCHED rather than leaking raw URLs into your endpoint list. A route that matched and deliberately returned 404 keeps its own name.

Logs and metrics are separate signals with separate wiring. See Logs and Metrics.

Next Steps

  • Exceptions: capture caught exceptions with context
  • Spans: create custom spans to measure sub-operations
  • Tasks: Messenger jobs and console commands on the Tasks page
  • Metrics: counters, histograms, gauges
  • Logs: forward Monolog / PSR-3 logs to TracePath
  • OpenTelemetry Overview: endpoint, authentication, limits and quota behaviour