Metrics
Record counters, histograms, gauges and up/down counters from a Symfony application and export them to TracePath as OTLP metrics.
Everything here needs OTEL_METRICS_EXPORTER=otlp in .env. That is already in the Quick Start env block. Metrics are a separate signal from traces: a working trace exporter sends no metrics at all.
Automatic metrics
Whether you get HTTP server metrics for free depends on which open-telemetry/opentelemetry-auto-* packages you installed and what they emit. TracePath imposes nothing here — whatever the instrumentation records arrives as an ordinary OTLP metric. Two things to know about how they land:
- Histograms are split. TracePath stores every explicit-bucket histogram as an
.avgand a.countseries, sohttp.server.request.durationappears in the dashboard ashttp.server.request.duration.avgandhttp.server.request.duration.count. See histogram handling. - Exponential histograms and summaries are dropped. They are discarded on ingest with no error. If a whole metric family is missing, check its aggregation first.
If you want HTTP latency and you are not getting it from instrumentation, record it yourself with a histogram — the section below shows how, and 15 lines in an event subscriber gets you the same series under a name you control.
Getting a meter
Instruments come from the global meter provider. This is plain OpenTelemetry PHP, so it works with or without the PECL extension:
use OpenTelemetry\API\Globals;
$meter = Globals::meterProvider()->getMeter('app');Create each instrument once and reuse it. Creating a counter on every request is wasteful and, for observable instruments, actively wrong — each call registers another callback. The clean shape is a small service that declares them in its constructor:
<?php
// src/Telemetry/AppMetrics.php
namespace App\Telemetry;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Metrics\CounterInterface;
use OpenTelemetry\API\Metrics\HistogramInterface;
use OpenTelemetry\API\Metrics\UpDownCounterInterface;
final class AppMetrics
{
public readonly CounterInterface $ordersCreated;
public readonly HistogramInterface $orderProcessingMs;
public readonly CounterInterface $paymentSuccess;
public readonly CounterInterface $paymentFailed;
public readonly UpDownCounterInterface $jobsActive;
public function __construct()
{
$meter = Globals::meterProvider()->getMeter('app');
$this->ordersCreated = $meter->createCounter('orders.created', 'orders', 'Total orders created');
$this->orderProcessingMs = $meter->createHistogram('orders.processing_ms', 'ms', 'Order processing time');
$this->paymentSuccess = $meter->createCounter('payments.success', 'payments');
$this->paymentFailed = $meter->createCounter('payments.failed', 'payments');
$this->jobsActive = $meter->createUpDownCounter('jobs.active', 'jobs', 'Jobs in flight');
}
}Autowiring registers it with no service definition. Symfony instantiates it once per process, which is exactly the lifetime an instrument wants.
Instrument types
Counter
Counters only go up. Use them for totals: orders placed, emails sent, cache hits.
$this->metrics->ordersCreated->add(1);
$this->metrics->ordersCreated->add(1, ['plan' => 'pro', 'region' => 'eu']);The array is the attribute set. Each distinct combination becomes its own series, so keep the values low cardinality. plan and region are fine, a customer id is not — that is one series per customer, and it will swallow your ingest quota.
Histogram
Histograms track a distribution: durations, payload sizes, item counts per batch.
$start = hrtime(true);
$this->processOrder($order);
$this->metrics->orderProcessingMs->record((hrtime(true) - $start) / 1e6);Up/down counter
Use an up/down counter for a value tracked by its changes rather than its absolute reading, such as items currently in flight:
$this->metrics->jobsActive->add(1);
try {
$this->run($job);
} finally {
$this->metrics->jobsActive->add(-1);
}Observable gauge
An observable gauge reads a value at export time, which suits anything you can query on demand rather than track incrementally. Register the callback once, in a constructor:
<?php
// src/Telemetry/QueueMetrics.php
namespace App\Telemetry;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Metrics\ObserverInterface;
final class QueueMetrics
{
public function __construct(private readonly QueueClient $queue)
{
Globals::meterProvider()
->getMeter('app')
->createObservableGauge('queue.depth', 'items', 'Pending jobs')
->observe(function (ObserverInterface $observer): void {
$observer->observe($this->queue->count(), ['queue' => 'async']);
});
}
}Because this registers a callback rather than running one, the service has to be instantiated for the gauge to exist. Symfony removes unused services, so either inject it somewhere or mark it public in your service configuration.
Using it
<?php
// src/Service/PaymentService.php
namespace App\Service;
use App\Entity\Order;
use App\Telemetry\AppMetrics;
class PaymentService
{
public function __construct(
private readonly AppMetrics $metrics,
) {}
public function processPayment(Order $order): void
{
$start = hrtime(true);
try {
$this->gateway->charge($order->total);
$this->metrics->paymentSuccess->add(1, ['plan' => $order->plan]);
} catch (\Throwable $e) {
$this->metrics->paymentFailed->add(1, ['plan' => $order->plan]);
throw $e;
} finally {
$this->metrics->orderProcessingMs->record((hrtime(true) - $start) / 1e6);
}
}
}Naming conventions
Use dot-separated names, lowercase, with the subject first:
// Good
'orders.created'
'db.query_ms'
'cache.hits'
// Bad
'created' // too vague
'orderCount' // inconsistent styleDo not prefix names with your service name. TracePath already separates series by service through the OTel resource, which comes from OTEL_SERVICE_NAME.
PHP-FPM records metrics per request
This is the one thing that surprises people coming from a long-running runtime. In PHP-FPM each request is a fresh process state: the meter provider is created, your counters are incremented, and the SDK exports and shuts down at the end of the request. A counter therefore reports the delta for that one request, not a value that accumulates in memory across requests.
That is fine — TracePath sums the deltas per bucket, so orders.created charts correctly. But it means two things:
- Do not try to read a counter's current value. There is no such API, and even if there were, the number would only cover the request you are in.
- Gauges want observable callbacks, not point recordings. A gauge you write once per request only has a value on requests that happened to run that code.
Long-running processes — Messenger workers, bin/console daemons — behave like any other runtime and batch normally.