Skip to Content
Course content

245: Distributed Tracing Across PHP Services

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you're probably comfortable logging errors within a single PHP application. But as soon as you split your logic into microservices—say, an Order service that calls a Payment service—your logs become a fragmented mess. You see an error in the Payment service, but you have no idea which specific Order request triggered it. This is where distributed tracing comes in.

We're going to build a tiny two-service system. I'll use OpenTelemetry (OTel) because it's the industry standard, and it keeps us from being locked into a specific vendor like New Relic or Datadog. We'll simulate a "Place Order" request that hits an order.php script, which then makes a CURL request to payment.php.

Setting up the Order Gateway

First, let's get the Order service to start a "root span." A span is essentially a timed segment of work. I want to wrap the entire order process in one span so I can see exactly how long the total request took.

<?php
require 'vendor/autoload.php';

use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\SDK\Trace\TracerProviderFactory;

$tracerProvider = (new TracerProviderFactory())->create();
$tracer = $tracerProvider->getTracer('order-service');

// Start the root span
$rootSpan = $tracer->spanBuilder('place_order_request')->startSpan();
$rootSpan->activate();

try {
    // Simulate some local work
    usleep(50000); 
    
    // We'll add the HTTP call here in a moment...
    echo "Order processed!";
} finally {
    $rootSpan->end();
    $tracerProvider->shutdown();
}
?>

Creating the Payment Receiver

Now we need the Payment service. This service needs to be able to "extract" a trace ID from the incoming request headers. If it finds one, it should attach its own work to that existing trace instead of starting a new one.

<?php
require 'vendor/autoload.php';

use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\SDK\Trace\TracerProviderFactory;

$tracerProvider = (new TracerProviderFactory())->create();
$tracer = $tracerProvider->getTracer('payment-service');

// Extract the context from the HTTP headers
$propagator = TraceContextPropagator::getInstance();
$context = $propagator->extract($_SERVER);

// Start a span that is a child of the extracted context
$span = $tracer->spanBuilder('process_payment')
    ->setParent($context)
    ->startSpan();

try {
    usleep(100000); // Simulate payment gateway latency
    echo "Payment successful!";
} finally {
    $span->end();
    $tracerProvider->shutdown();
}
?>

The "Ghost Trace" Problem

Here is where I usually trip up when I'm first setting this up. I'll write the code to call the payment service using CURL, but I'll forget that the trace ID doesn't magically travel through the air—it has to be explicitly sent in the HTTP headers.

I'll add this to my order.php:

$ch = curl_init('http://payment.php');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

When I run this and check my tracing dashboard (like Jaeger), I see two completely separate traces: one for the order and one for the payment. They have different Trace IDs. To the system, they look like two unrelated events. This is a "ghost trace"—the connection is missing because I didn't propagate the context.

Wiring up the Propagation

To fix this, I need to use the TraceContextPropagator to inject the current span's ID into the CURL headers. This tells the Payment service, "Hey, you're part of Trace X."

Here is the corrected logic for the order.php request:

$propagator = TraceContextPropagator::getInstance();
$headers = [];

// This injects the traceparent header into our $headers array
$propagator->inject($headers);

// Now we convert that array into a format CURL understands
$curlHeaders = [];
foreach ($headers as $key => $value) {
    $curlHeaders[] = "$key: $value";
}

$ch = curl_init('http://payment.php');
curl_setopt($ch, CURLOPT_HTTPHEADER, $curlHeaders);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

Now, when the Payment service calls $propagator->extract($_SERVER), it finds the traceparent header I just sent. The two spans are now linked in a single timeline. You can now see that the 150ms total request time was actually 50ms in the Order service and 100ms in the Payment service. That's the power of distributed tracing.




📋 Practical Task

Exercise: Fixing the Broken Shipping Trace

You have been handed a legacy distributed system. The OrderService calls the ShippingService, but the traces are broken. The ShippingService is already correctly using TraceContextPropagator::getInstance()->extract($_SERVER), but the OrderService is sending the request using a Guzzle HTTP client without any trace headers.

Your Task: Modify the OrderService code below to inject the current OpenTelemetry trace context into the Guzzle request headers so that the ShippingService can link the two spans together.

// OrderService.php
require 'vendor/autoload.php';

use GuzzleHttp\Client;
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\SDK\Trace\TracerProviderFactory;

$tracerProvider = (new TracerProviderFactory())->create();
$tracer = $tracerProvider->getTracer('order-service');
$span = $tracer->spanBuilder('ship_order')->startSpan();
$span->activate();

$client = new Client();

// TODO: Inject the trace context into the headers array here
$headers = []; 

$response = $client->request('GET', 'http://shipping-service/api/ship', [
    'headers' => $headers
]);

$span->end();
$tracerProvider->shutdown();
Rating
0 0

There are no comments for now.

to be the first to leave a comment.