All posts

OpenTelemetry, Symfony i Grafana Tempo — tracing w praktyce

August 25, 2026Michał Woliński11 min read
OpenTelemetry, Symfony i Grafana Tempo — tracing w praktyce

You can spot a Symfony application the moment latency spikes and no one can tell you which request, which query, or which service call caused it. Logs after the fact, profiling only when someone has time, and a production incident that takes an hour to reconstruct. Distributed tracing fixes exactly that — and OpenTelemetry is the open standard to get it without locking yourself into a vendor.

In this article I will walk you through a complete, working setup: a Symfony 7 application running in Docker, instrumented with OpenTelemetry, exporting traces over OTLP HTTP to Grafana Tempo, and visualised as Gantt-style traces in Grafana. You will end with infrastructure that shows you the full path of every HTTP request — the controller, the Doctrine query, the external API call — in a few clicks.

What We Are Building

Distributed tracing records a single logical operation — a request — as a trace made of spans. Each span is one unit of work: handle request, SELECT orders, call payment-api. Spans carry a duration, attributes, and a parent-child relationship. When you connect them, you get the complete story of a request.

The pieces we wire together:

  • Symfony 7 — our PHP application, running in a Docker container.
  • OpenTelemetry PHP extension (ext-opentelemetry) — the native, zero-code library that observes method calls at the PHP engine level.
  • OpenTelemetry PHP SDK — the library that collects and exports the spans, configured entirely through environment variables.
  • Symfony instrumentation — the package that tells the extension what to observe in the framework.
  • OTLP HTTP exporter — sends the spans over HTTP to a backend using the standard OpenTelemetry protocol.
  • Grafana Tempo — a scalable, open-source trace backend that ingests OTLP and serves traces to Grafana.
  • Grafana — the UI where we explore the traces as searchable Gantt timelines.

The whole thing runs locally in Docker Compose, so you can copy it, run it, and see your own request traces in minutes.

How PHP OpenTelemetry Actually Works

Before the code, one mental model is worth getting right. PHP does not do automatic instrumentation the way a Java agent does, by rewriting bytecode. Instead it ships a native extension, ext-opentelemetry, which registers hook callbacks around method calls at the engine level (the Zend observer API). Instrumentation packages then tell that extension which application and framework classes to observe.

That is why a Symfony auto-instrumentation setup needs exactly three layers:

  • the extension (installed via PECL or the Docker PHP extension installer),
  • the instrumentation library for Symfony, released as open-telemetry/opentelemetry-auto-symfony, which hooks HttpKernel::handle to create the root span,
  • the SDK and an exporter, which actually collect and ship the spans.

The extension alone produces nothing. The SDK alone does not hook anything. It only works when the extension, an instrumentation library, the SDK, and an exporter are all present.

Project Layout And Docker Compose

Everything is declared as code, so the dev environment is reproducible. We run the app in a PHP-FPM container and the observability stack next to it.

# docker-compose.yml services: php: build: context: . dockerfile: Dockerfile # PHP-FPM speaks FastCGI on 9000, not HTTP — the built-in server is the # simplest way to actually serve :8000 for local dev/testing. command: php -S 0.0.0.0:8000 -t public volumes: - .:/var/www/html environment: APP_ENV: dev # Enable zero-code autoloading of the extension OTEL_PHP_AUTOLOAD_ENABLED: "true" OTEL_SERVICE_NAME: symfony-app OTEL_TRACES_EXPORTER: otlp OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf # Tempo's OTLP HTTP receiver; the SDK appends /v1/traces OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318 OTEL_PROPAGATORS: tracecontext,baggage ports: - "8000:8000" depends_on: tempo-ready: condition: service_completed_successfully tempo: image: grafana/tempo:3.0.3 command: -config.file=/etc/tempo.yaml volumes: - ./docker/tempo.yaml:/etc/tempo.yaml # grafana/tempo has no shell and no curl/wget, so it can't run a native # `healthcheck:` itself. Its own /ready returns 200 seconds before the # live-store partition can actually accept spans for querying (Tempo 3.0's # partition-ring warm-up), so this polls the one metric that reflects true # readiness and exits once it flips to Active. tempo-ready: image: curlimages/curl:8.10.1 depends_on: - tempo entrypoint: - sh - -c - > until curl -sf http://tempo:3200/metrics | grep -q 'tempo_partition_ring_partitions{name="livestore-partitions",state="Active"} 1'; do sleep 1; done grafana: image: grafana/grafana:13.2.0 environment: GF_AUTH_ANONYMOUS_ENABLED: "true" GF_AUTH_ANONYMOUS_ORG_ROLE: Editor ports: - "3000:3000" volumes: - ./docker/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/grafana-datasources.yaml depends_on: - tempo

The app talks to tempo on port 4318 — that is the OTLP HTTP receiver. The variable OTEL_EXPORTER_OTLP_ENDPOINT is the base endpoint; the exporter appends the /v1/traces path itself. Tempo itself is a deliberately simple trace store: it ingests OTLP and indexes traces so Grafana can query them. It does not analyse anything on its own, which is exactly what you want from a scalable trace backend.

The php -S 0.0.0.0:8000 -t public command on the PHP service is a local-development convenience: PHP-FPM speaks FastCGI, and there is no HTTP server in the base image, so the built-in server is what actually serves :8000 here. In a real deployment PHP-FPM sits behind nginx or Caddy instead of the built-in server.

You will also notice a tempo-ready service. Tempo 3.0 runs a partitioned live-store, and for the first ~15-20 seconds after startup its partition is in PartitionPending state: the OTLP receiver accepts your spans, but they are silently dropped instead of queued. tempo-ready polls Tempo's /metrics and exits only once the partition flips to Active; because php waits on it via depends_on: condition: service_completed_successfully, docker compose up -d structurally does not return until Tempo is really ready — so you never lose the traces from your very first request.

Enable The Extension In The Dockerfile

The extension is easiest to install with the official install-php-extensions, which builds it reliably from source on the official PHP images. The official php:8.5-fpm image ships neither Composer nor unzip, so the Dockerfile adds both before the SDK install in the next step — without them, composer require fails with command not found: composer, and even after installing Composer the archive install breaks with zip extension and unzip/7z commands are both missing.

# Dockerfile FROM php:8.5-fpm # mlocati builder installs build deps and cleans up automatically COPY --from=mlocati/php-extension-installer:latest /usr/bin/install-php-extensions /usr/bin/ # the base image has no Composer — bring it from the official composer image COPY --from=composer:2 /usr/bin/composer /usr/bin/composer # unzip: Composer needs it to install packages from dist archives RUN apt-get update && apt-get install -y --no-install-recommends unzip \ && rm -rf /var/lib/apt/lists/* RUN set -eux \ && install-php-extensions \ opentelemetry \ protobuf \ pdo_mysql \ opcache \ intl WORKDIR /var/www/html

After the image builds, verify the extension is loaded:

docker compose exec php php -m | grep opentelemetry # opentelemetry

Install The SDK And Symfony Instrumentation

With the extension in place, install the SDK, the exporter, and the Symfony auto-instrumentation package from Packagist.

composer require \ open-telemetry/sdk \ open-telemetry/exporter-otlp \ open-telemetry/opentelemetry-auto-symfony

Because the OTLP exporter serialises traces as protobuf, you also need a protobuf implementation. The protobuf extension, already added to the Dockerfile above, satisfies it. (If you prefer no native extensions, the pure-PHP google/protobuf package is the fallback.) The SDK and exporter resolve the API versions they need through Composer, so the graph stays consistent.

Configure Zero-Code Instrumentation

Once the extension, the SDK, and the Symfony instrumentation are present, no application code is required to start a root span. The whole thing is configured through environment variables.

OTEL_PHP_AUTOLOAD_ENABLED=true OTEL_SERVICE_NAME=symfony-app OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318 OTEL_PROPAGATORS=tracecontext,baggage

These same variables can live in php.ini instead of the shell. The ones that matter:

  • OTEL_PHP_AUTOLOAD_ENABLED — lets the extension register its hooks via Composer's autoloader.
  • OTEL_TRACES_EXPORTER=otlp — tells the SDK which exporter to build.
  • OTEL_EXPORTER_OTLP_ENDPOINT — the base URL of the OTLP receiver; the SDK appends /v1/traces.
  • OTEL_PROPAGATORS — tracecontext carries the traceparent header across HTTP.

With that in place, requesting any Symfony route produces a root span, Doctrine queries and HTTP client calls become child spans, and the trace context propagates to outgoing HTTP requests — all without writing a single span-builder call.

The Tempo Configuration

Tempo's config is deliberately small. We enable the OTLP HTTP receiver and a block store on disk. If you have S3 or GCS, Tempo can use object storage later; for local dev, files on disk are perfect.

# docker/tempo.yaml server: http_listen_port: 3200 distributor: receivers: otlp: protocols: http: endpoint: 0.0.0.0:4318 storage: trace: backend: local wal: path: /var/tempo/wal local: path: /var/tempo/blocks block: version: vParquet4 # Tempo 3.0's query path delays traces by ~30s by default # (query_frontend.query_end_cutoff). Flushing recent data more eagerly # and cutting the query window at 1s makes a freshly-ingested trace # searchable in a few seconds instead of half a minute. live_store: max_trace_idle: 1s flush_check_period: 1s query_frontend: query_end_cutoff: 1s

The receiver we care about is otlp.http on port 4318. The PHP exporter posts protobuf traces there, and Tempo stores them. The storage.trace block declares: the backend (here local for development), the write-ahead log path, the local backend path, and — optionally — the block format (vParquet4 is the default in Tempo 3.0, so that line is optional but explicit). Note there is no separate index path: Tempo does not maintain a dedicated search index. It reads traces directly from the immutable blocks and uses bloom filters for lookups, which is one of the reasons it stays simple to operate.

Provision The Grafana Datasource

Grafana connects to Tempo through a datasource. Provisioning files let us declare it in code instead of clicking through the UI.

# docker/grafana-datasources.yaml apiVersion: 1 datasources: - name: Tempo type: tempo uid: tempo url: http://tempo:3200 isDefault: true access: proxy

Grafana talks to Tempo on port 3200 — that is Tempo's HTTP query API (the query frontend runs in-process in this single-container, monolithic Tempo). With the datasource marked default, the Explore tab routes straight to traces.

Manual Spans For Business Logic

Zero-code instrumentation covers the framework. For business logic — a long calculation, a bespoke external call, a critical decision — you want explicit spans with your own attributes. Here is the important part: you do not build a second provider. The SDK already created one global TracerProvider from those environment variables, and the zero-code instrumentation uses exactly that instance. You fetch the same provider with Globals::tracerProvider() so manual and automatic spans stay in one trace.

<?php namespace App\OpenTelemetry; use OpenTelemetry\API\Globals; use OpenTelemetry\API\Trace\TracerProviderInterface; final class Tracing { public static function tracerProvider(): TracerProviderInterface { // Returns the same global provider the SDK built from env config, // so manual spans share the root trace with auto-instrumentation. return Globals::tracerProvider(); } }

Expose it as a service through dependency injection, and inject the tracer where you need it.

# config/services.yaml services: OpenTelemetry\API\Trace\TracerProviderInterface: factory: [App\OpenTelemetry\Tracing, 'tracerProvider'] App\Controller\OrderController: arguments: $provider: '@OpenTelemetry\API\Trace\TracerProviderInterface'

Then wrap the work you want to make visible in a span:

<?php namespace App\Controller; use OpenTelemetry\API\Trace\TracerProviderInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; use App\Entity\Order; final class OrderController extends AbstractController { public function __construct( private readonly TracerProviderInterface $provider, ) { } #[Route('/orders/{id}', methods: ['GET'])] public function show(int $id): JsonResponse { $tracer = $this->provider->getTracer('app.order'); $span = $tracer->spanBuilder('order.show')->startSpan(); $span->setAttribute('order.id', $id); try { // fetchOrder() is a stand-in for your real repository query $orders = $this->fetchOrder($id); return $this->json(['id' => $id, 'total' => $orders->getTotal()]); } finally { $span->end(); } } private function fetchOrder(int $id): Order { // your Doctrine / repository call here throw new \LogicException('replace with your query'); } }

Build a span, set attributes that make it searchable, wrap the work in try/finally so the span always closes, and let the global SDK ship it. Because the controller's span starts with the kernel root span as its parent, the timeline in Grafana is correct.

Sampling With Environment Variables

You do not want to export every span forever. Sampling is the knob that keeps a trace backend affordable — and in the zero-code model you configure it through the same environment variables as everything else, not by writing code.

OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.1

The SDK's sampler factory reads these variables. With parentbased_traceidratio, the ratio-based decision is made once, at the root span of a trace: a root is sampled by its own probability, and every descendant then inherits that decision. A trace is therefore never sampled halfway through — if the root is kept, the whole trace is kept. 0.1 keeps roughly 10% of traces.

This keeps the whole configuration in one place — no hand-built sampler, no second provider that can drift from the one auto-instrumentation uses.

Tracing Context Across HTTP

Distributed tracing only works when the trace context travels with each request. OpenTelemetry propagates it through the traceparent header. If your Symfony app calls a second service that also exports to Tempo, you want the same trace to span both.

The Symfony auto-instrumentation injects context into HTTP client calls automatically. The outgoing request carries the parent trace id, and the two services land in one trace:

<?php // With auto-instrumentation the HttpClient span + traceparent // are created for you. This controller just calls through it. final class PaymentGateway { public function __construct( private readonly HttpClientInterface $httpClient, ) { } public function authorise(int $amount): array { $response = $this->httpClient->request('POST', 'https://pay.example/authorise', [ 'json' => ['amount' => $amount], ]); return $response->toArray(); } }

As long as both sides share the same traceparent and both export to Tempo, the trace joins them into a single timeline.

Viewing The Traces In Grafana

With everything running — docker compose up — hit your endpoint a few times to generate traces, then open Grafana at http://localhost:3000 and go to Explore. The Tempo datasource is defaulted, so you land straight on a search screen. Landing on Explore does not run a query by itself — switch the query-type selector to TraceQL, paste your query, and press Run query (or Shift+Enter). If the panel still shows nothing, check the time range picker in the top-right corner covers "now" — a narrowed range is the most common reason a trace that clearly exists in Tempo doesn't show up in Grafana.

Search Tempo by service name (symfony-app) or by a span attribute such as order.id. Open a trace and you see a Gantt-style waterfall: the root request span at the top, and below it every child span — the Doctrine query, the HTTP call, your manual spans — each with its duration. Hover any span to see its full attribute set, including the ones you set yourself.

You can query Tempo directly from Grafana with the TraceQL language. A minimal TraceQL expression is just:

 { .service.name = "symfony-app" }

That returns every trace from our service. Add an attribute filter:

 { .service.name = "symfony-app" && .order.id = 42 }

This is the power of setting good attributes at span creation — they become first-class search dimensions without any indexing setup.

Troubleshooting The Pipeline

Whenever your traces are missing, work the pipeline backward in order.

First, is the extension actually loaded? Run docker compose exec php php -m | grep opentelemetry. If it is absent, the extension did not build and no instrumentation runs at all.

Second, is auto-instrumentation enabled and the SDK resolving? Confirm OTEL_PHP_AUTOLOAD_ENABLED=true, that Composer autoload runs, and that the instrumentation package is installed. A common gotcha is the endpoint: OTEL_EXPORTER_OTLP_ENDPOINT is the base URL — the exporter appends /v1/traces itself, so do not add it to the env var.

Third, is tempo actually receiving spans? Check the Tempo container logs or its /status endpoint for ingested span counts. Confirm the http/protobuf protocol matches what Tempo exposes on 4318 (gRPC would be 4317).

Finally, is Grafana finding the datasource? Verify the provisioning file was mounted and the datasource is marked default. If spans are present but show as orphans, check that tracecontext propagation is enabled on the outgoing clients.

Summary And Next Steps

With a few environment variables, one Dockerfile line, and a couple of Composer packages, a Symfony 7 app in Docker now exports OpenTelemetry traces over OTLP HTTP to Grafana Tempo, viewable in Grafana. The slow secret hidden in a Doctrine query, the third-party call that doubles your latency — both become visible as named spans with durations, searchable by attribute.

Where to go next:

  • Add metric and log pipelines with the same SDK — Tempo handles traces, while Prometheus and Loki cover the other two pillars under the same Grafana umbrella.
  • Instrument Messenger consumers — the Symfony package already hooks the message bus, so each job is a trace.
  • Run a sampler strategy tuned to your traffic — 100% in staging, a ratio in production, and always keep errors.
  • Move Tempo's storage to S3 or GCS for production durability.

Prefer to skip the step-by-step? A complete, working implementation — the same Symfony 7 app, Docker, Tempo, and Grafana wiring — lives in opentelemetry-symfony-grafana, built and verified end to end. Clone it and you get a working starting point without tracing the article's steps by hand.

OpenTelemetry is the layer that makes observability vendor-neutral. Once the extension and SDK are in the app, moving from Tempo to Jaeger or a SaaS tracing provider is a config change, not a rewrite. Getting it in early — even in a local Docker compose — is the cheapest way to build the habit that saves you an hour on the next incident.