Metrics

Counters, histograms and gauges: the few numbers worth alerting on, and how to expose them for a scraper.

Syntax$registry->getOrRegisterCounter(string $namespace, string $name, string $help, array $labels = [])

A metric is a number recorded over and over, each with a timestamp. Not a line of text describing one event, but a count or a measurement that can be summed, averaged and kept for months. 4,812 requests in the last minute, 23 of them failures is metrics. It is the cheapest signal you have: a handful of numbers per request, no matter how much traffic there is.

Symfony 8.1 has no metrics component. You add a client library, record numbers during the request, and expose them on a URL for a scraper: a process that fetches that URL every few seconds and stores what it finds. The usual pair is Prometheus as the scraper and promphp/prometheus_client_php as the library.

The handful worth having

  • A counter of requests, labelled by route and status. A counter only ever goes up.
  • A histogram of request duration. It stores counts per bucket ("how many finished under 100ms"), which is what lets a percentile be worked out later.
  • A gauge for values that move both ways: queue depth, workers alive.
  • Counters for things a user would notice: orders placed, emails sent, logins refused.

What trips people up

Labels multiply. One time series exists per combination of label values, so a label holding a user id, or a raw path like /articles/4193, creates a series per id. Use the route name, which Symfony stores in the _route request attribute.

The #[AsEventListener] attribute below is shorthand. The longhand is a service tag in config/services.yaml: tags: [{ name: kernel.event_listener, event: kernel.terminate, method: __invoke }]. They register the same listener.

Example

Example · php
// src/Observability/HttpMetricsListener.php
declare(strict_types=1);

namespace App\Observability;

use Prometheus\CollectorRegistry;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;

// kernel.terminate runs after the response has been sent, so recording
// these numbers costs the user no waiting time.
#[AsEventListener(event: KernelEvents::TERMINATE)]
final class HttpMetricsListener
{
    public function __construct(private readonly CollectorRegistry $registry)
    {
    }

    public function __invoke(TerminateEvent $event): void
    {
        $request = $event->getRequest();

        // The route name, never the path: the path carries ids.
        $route = (string) $request->attributes->get('_route', 'unmatched');
        $status = (string) $event->getResponse()->getStatusCode();

        $this->registry->getOrRegisterCounter(
            'app',
            'http_requests_total',
            'HTTP requests handled',
            ['route', 'status'],
        )->inc([$route, $status]);

        $started = (float) $request->server->get('REQUEST_TIME_FLOAT');

        $this->registry->getOrRegisterHistogram(
            'app',
            'http_request_duration_seconds',
            'Wall time spent handling a request',
            ['route'],
            [0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
        )->observe(microtime(true) - $started, [$route]);
    }
}

When to use it

  • Find out which route is responsible for the latency the users are complaining about.
  • Alert when more than 2% of requests return 5xx, measured over five minutes.
  • Watch orders per minute during a deploy to confirm checkout still works.
  • Graph Messenger queue depth to decide whether to add consumer replicas.

More examples

The endpoint the scraper reads

GET /metrics returns 200 with a plain-text body, one line per time series, such as app_http_requests_total{route="app_article_show",status="200"} 41. The scraper parses that text; nothing else reads it. Keep the route off the public internet: restrict it to the cluster network, or put it behind a role, because the metric names alone describe your internals. Note the controller does not extend AbstractController, because it needs none of its helpers.

Example · php
// src/Controller/MetricsController.php
declare(strict_types=1);

namespace App\Controller;

use Prometheus\CollectorRegistry;
use Prometheus\RenderTextFormat;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class MetricsController
{
    public function __construct(private readonly CollectorRegistry $registry)
    {
    }

    #[Route('/metrics', name: 'app_metrics', methods: ['GET'])]
    public function __invoke(): Response
    {
        $renderer = new RenderTextFormat();

        return new Response(
            $renderer->render($this->registry->getMetricFamilySamples()),
            Response::HTTP_OK,
            ['Content-Type' => RenderTextFormat::MIME_TYPE],
        );
    }
}

Wiring the registry so one storage is shared

Reach for this the moment you install the library, because the default storage is per-request and silently gives you almost-empty output. APC memory is local to the PHP process pool, which means each container reports only its own traffic. That is normal: the scraper collects from every replica and adds them together. If replicas come and go quickly, or you run many pools, use the Redis adapter instead so the counters live outside the process.

Example · yaml
# config/services.yaml
services:
    # APC storage keeps counters in shared memory, so numbers recorded by
    # one request are still there when the scraper's request arrives.
    Prometheus\Storage\APC: ~

    Prometheus\CollectorRegistry:
        arguments: ['@Prometheus\Storage\APC']

A counter for something a user would notice

Technical metrics tell you the application is answering; business counters tell you it is working. A deploy that breaks the payment form keeps a flat error rate and a flat latency graph, because the broken page returns 200 - but orders_placed_total stops climbing. That is often the fastest signal you will get. Use a counter for events and a gauge for a current value; a gauge of a total would be wrong, because gauges are not summed across replicas.

Example · php
// src/Checkout/OrderPlacer.php
declare(strict_types=1);

namespace App\Checkout;

use Prometheus\CollectorRegistry;

final class OrderPlacer
{
    public function __construct(private readonly CollectorRegistry $registry)
    {
    }

    public function record(string $currency, int $itemCount): void
    {
        // 'currency' has a few possible values. 'customer_id' would have
        // millions, and each one would become its own stored time series.
        $this->registry->getOrRegisterCounter(
            'app',
            'orders_placed_total',
            'Orders that reached the paid state',
            ['currency'],
        )->inc([$currency]);

        $this->registry->getOrRegisterGauge(
            'app',
            'last_order_items',
            'Item count of the most recent order',
            ['currency'],
        )->set($itemCount, [$currency]);
    }
}

Discussion

  • Be the first to comment on this lesson.