OpenTelemetry is the emerging standard for distributed tracing, metrics, and logs. Where Blackfire shows you what is slow within a single PHP process, OpenTelemetry shows you what happens across multiple services – from the Magento web server through Redis, MySQL, and external APIs, to the queue consumer. I show auto-instrumentation setup, Jaeger in DDEV for viewing traces, and adding custom spans to your own code.
What OpenTelemetry adds over Blackfire
| Aspect | Blackfire | OpenTelemetry |
|---|---|---|
| Scope | Single PHP process | Distributed across all services |
| Services | PHP only | PHP, MySQL, Redis, RabbitMQ, HTTP APIs |
| Activation | On-demand (pull) | Always on (push to collector) |
| Production | On-demand profiling | Continuous low-overhead tracing |
| Use case | Optimising specific requests | Understanding system behaviour |
Setup: OpenTelemetry PHP SDK + Jaeger in DDEV
composer require \
open-telemetry/sdk \
open-telemetry/exporter-otlp \
open-telemetry/opentelemetry-auto-psr3 \
open-telemetry/opentelemetry-auto-pdo \
open-telemetry/opentelemetry-auto-guzzle
# .ddev/docker-compose.jaeger.yaml
version: '3.6'
services:
jaeger:
image: jaegertracing/all-in-one:1.55
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- "16686:16686" # Jaeger UI
- "4317" # OTLP gRPC
- "4318" # OTLP HTTP
labels:
com.ddev.site-name: ${DDEV_SITENAME}
# php.ini - enable auto-instrumentation [opentelemetry] extension=opentelemetry.so OTEL_SERVICE_NAME=magento2 OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_TRACES_EXPORTER=otlp OTEL_PHP_AUTOLOAD_ENABLED=true OTEL_PROPAGATORS=baggage,tracecontext
Manual instrumentation – custom spans
<?php
declare(strict_types=1);
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
class ProductImportService
{
private \OpenTelemetry\API\Trace\TracerInterface $tracer;
public function __construct(
private \Vendor\Module\Model\Api\ExternalApiClient $apiClient,
private \Magento\Catalog\Api\ProductRepositoryInterface $productRepository
) {
$this->tracer = Globals::tracerProvider()->getTracer('vendor.import');
}
public function importBatch(array $skus): array
{
$span = $this->tracer->spanBuilder('import.batch')
->setAttribute('import.sku_count', count($skus))
->setSpanKind(SpanKind::KIND_INTERNAL)
->startSpan();
$scope = $span->activate();
$results = ['success' => 0, 'failed' => 0];
try {
foreach ($skus as $sku) {
$this->importSingle($sku, $results);
}
$span->setAttribute('import.success_count', $results['success']);
$span->setAttribute('import.failed_count', $results['failed']);
$span->setStatus(StatusCode::STATUS_OK);
} catch (\Exception $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$scope->detach();
$span->end();
}
return $results;
}
private function importSingle(string $sku, array &$results): void
{
$span = $this->tracer->spanBuilder('import.single')
->setAttribute('product.sku', $sku)
->startSpan();
$scope = $span->activate();
try {
// Fetch from external API - this span will appear nested in Jaeger
$data = $this->apiClient->getProduct($sku);
if ($data === null) {
$span->setAttribute('product.found', false);
$results['failed']++;
return;
}
// Save to Magento - SQL queries will appear as child spans automatically
$this->saveProduct($data);
$span->setAttribute('product.found', true);
$results['success']++;
} catch (\Exception $e) {
$span->recordException($e);
$results['failed']++;
} finally {
$scope->detach();
$span->end();
}
}
}
Viewing traces in Jaeger
# After ddev restart, Jaeger UI is at: # http://localhost:16686 # Run a Magento request to generate traces curl https://magento2-dev.ddev.site/catalog/product/view/id/42 # In Jaeger UI: # 1. Select service: magento2 # 2. Click "Find Traces" # 3. Click on a trace to see the waterfall # Typical trace for a product page: # - magento2: request (total time) # |- magento2: database.query (SELECT catalog_product_entity...) # |- magento2: database.query (SELECT catalog_product_index_price...) # |- magento2: redis.get (cache lookup) # |- magento2: redis.set (cache write) # |- magento2: database.query (x18 more queries...)
Trace propagation across services
<?php
// When Magento calls an external service (ERP, PIM, payment gateway),
// propagate the trace context so you can see the full distributed trace
use GuzzleHttp\Client;
use OpenTelemetry\API\Globals;
use OpenTelemetry\Context\Propagation\TraceContextPropagator;
class ErpClient
{
public function __construct(private Client $httpClient) {}
public function createOrder(array $orderData): array
{
$headers = [];
// Inject trace context into outgoing request headers
// The ERP service (if also instrumented) will continue the same trace
TraceContextPropagator::getInstance()->inject($headers);
$response = $this->httpClient->post('/api/orders', [
'headers' => $headers, // includes traceparent header
'json' => $orderData,
]);
return json_decode($response->getBody(), true);
}
}
Summary
OpenTelemetry provides the “big picture” view that Blackfire cannot – distributed traces across PHP, databases, caches, and external services in a single waterfall diagram. Auto-instrumentation with the PHP SDK instruments PDO, HTTP clients, and PSR-3 loggers with zero code changes. Custom spans add business-level visibility to your own code. Jaeger in DDEV takes 10 minutes to set up and immediately answers “why is this request slow” across service boundaries.
