Guide to implementing distributed tracing with OpenTelemetry in a PHP monolith

By · Published on · Updated on · 9 min read

monochrome patterns with square blocks forming a sinuous trace on a light background
Abstract representation of flow and connection, suitable for distributed tracing in architecture Photo: Google DeepMind / Pexels

This guide walks you through implementing distributed tracing with OpenTelemetry in a PHP monolith. You will instrument HTTP requests, database queries, external API calls and jobs, configure OTLP exporters and set safe sampling for production.

Why implement tracing in a PHP monolith

Even monoliths have many blind spots: controllers, internal services, queues, cron jobs and API calls. Distributed tracing stitches everything into a single timeline.

It speeds up diagnosis of latency, SQL N+1s, I/O bottlenecks and intermittent failures. You measure each step and see how a request travels through the system.

If you’re still evaluating tools, see the comparison of observability options for PHP: when to use OpenTelemetry vs proprietary agents.

To complete observability, correlate traces with structured logs and metrics. Correlation significantly reduces incident investigation time.

  • Identify the slowest routes and endpoints.
  • See external dependencies with the highest latency.
  • Isolate SQL queries and cache misses.
  • Trace jobs and cron tasks that cause load spikes.

Minimal architecture and choosing the exporter

The basic architecture includes: the OpenTelemetry SDK for PHP, propagators for context (traceparent), a sampler and an exporter. In production it’s common to send spans to an OpenTelemetry Collector, which forwards them to a traces backend.

OTLP is the ecosystem’s standard. You can use HTTP/protobuf (port 4318) or gRPC (port 4317), depending on network and security policies.

If you already have a backend that accepts Zipkin or Jaeger, the Collector translates and buffers. This avoids coupling your monolith to the final provider.

Exporter Protocol When to use Notes
OTLP/HTTP HTTP 4318 Environments with restrictive proxies and firewalls Default; easy to debug with HTTP tools.
OTLP/gRPC gRPC 4317 Low latency and long-lived connections May require specific rules on the load balancer.
Zipkin HTTP 9411 Compatibility with legacy collectors Use via Collector to avoid coupling the application.
Jaeger UDP/HTTP Environments with existing Jaeger Prefer OTLP via Collector when possible.

Prerequisites and rollout planning

Plan before touching code. Define a service naming and versioning standard. Decide the initial sampling and exporter endpoints for each environment.

Protect privacy: do not capture sensitive data like passwords or card numbers. Prefer technical IDs and hashes. Review attributes and events before go‑live.

Ensure the monolith’s front controller is the single point to start and end root spans. In frameworks, use middleware to wrap the request.

  • PHP 8.x with Composer.
  • Access to edit the app bootstrap or middleware.
  • OTLP/Collector endpoint per environment.
  • Initial sampling strategy (e.g. 5–10%).
  • Attribute conventions (http., db., net.*).

Installing and bootstrapping OpenTelemetry in the PHP monolith

Install the SDK packages and the OTLP exporter via Composer. Then create a tracing bootstrap to register the TracerProvider, sampler, processor and exporter.

Class names can vary by SDK version. Check the namespace installed by Composer and adjust if necessary. The example below uses OTLP/HTTP.

Load this bootstrap in the front controller (e.g. public/index.php) to ensure each request has a root span and that shutdown flushes the spans.

composer require open-telemetry/sdk open-telemetry/exporter-otlp

Bootstrap example: provider, sampler and OTLP/HTTP exporter

This example shows a typical configuration: named service, parent-based sampler with a ratio, batch processing and OTLP/HTTP export to a Collector. Adjust environment variables per your environment.

Use environment variables for endpoints and sampling so you can change settings without redeploying. Shutting down the provider at the end of the request ensures spans are sent.

<?php
// bootstrap/tracing.php

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor;
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
use OpenTelemetry\SDK\Trace\Sampler\TraceIdRatioBased;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\Contrib\Otlp\Exporter as OtlpExporter;

$serviceName = getenv('OTEL_SERVICE_NAME') ?: 'php-monolith';
$endpoint = getenv('OTEL_EXPORTER_OTLP_ENDPOINT') ?: 'http://localhost:4318';
$sampleRatio = (float) (getenv('OTEL_TRACES_SAMPLER_ARG') ?: '0.1');

$transport = (new OtlpHttpTransportFactory())
    ->create(rtrim($endpoint, '/') . '/v1/traces', 'application/x-protobuf');
$exporter = new OtlpExporter($transport);

$processor = new BatchSpanProcessor($exporter);
$sampler = new ParentBased(new TraceIdRatioBased($sampleRatio));

$tracerProvider = new TracerProvider($processor, $sampler);
Globals::setTracerProvider($tracerProvider);

// Extração do contexto de entrada (W3C traceparent)
$headers = function_exists('getallheaders') ? getallheaders() : [];
$propagator = TraceContextPropagator::getInstance();
$carrier = $headers;
$getterSetter = new ArrayAccessGetterSetter();
$context = $propagator->extract($carrier, $getterSetter);

// Tracer para a aplicação
$tracer = Globals::tracerProvider()->getTracer($serviceName);

// Span raiz da requisição
$span = $tracer->spanBuilder('http.request')->setParent($context)->startSpan();
$span->setAttribute('service.name', $serviceName);
$span->setAttribute('http.method', $_SERVER['REQUEST_METHOD'] ?? 'CLI');
$span->setAttribute('http.target', $_SERVER['REQUEST_URI'] ?? '');
$span->activate();

register_shutdown_function(function () use ($span, $tracerProvider) {
    $span->end();
    $tracerProvider->shutdown();
});

Instrument requests, SQL and jobs with child spans

With the tracer running, create child spans for critical operations. Name spans by purpose and add key attributes. Record exceptions on spans to aid triage.

The example creates spans for the controller, a PDO query and a queue job. In frameworks adapt this to middleware or specific handlers.

<?php
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;

$tracer = Globals::tracerProvider()->getTracer('php-monolith');

// Controlador/ação
$controllerSpan = $tracer->spanBuilder('controller:OrdersController@index')
    ->setSpanKind(SpanKind::KIND_INTERNAL)
    ->startSpan();
try {
    // ... lógica do controlador

    // SQL com PDO
    $dbSpan = $tracer->spanBuilder('db.query:orders_by_customer')->startSpan();
    try {
        $dbSpan->setAttribute('db.system', 'mysql');
        $dbSpan->setAttribute('db.statement', 'SELECT * FROM orders WHERE customer_id = ?');
        // $stmt = $pdo->prepare('SELECT * FROM orders WHERE customer_id = ?');
        // $stmt->execute([$customerId]);
    } finally {
        $dbSpan->end();
    }

    // Job de fila
    $jobSpan = $tracer->spanBuilder('queue:send_invoice_email')->startSpan();
    try {
        $jobSpan->setAttribute('messaging.system', 'redis');
        $jobSpan->setAttribute('messaging.destination', 'emails');
        // enqueue(...)
    } finally {
        $jobSpan->end();
    }

} catch (Throwable $e) {
    $controllerSpan->recordException($e);
    $controllerSpan->setAttribute('exception.escaped', true);
    throw $e;
} finally {
    $controllerSpan->end();
}

Context propagation in external calls (traceparent)

Propagate the context when calling APIs. This chains spans across services. Extract on inbound requests and inject on outbound. The W3C traceparent is the standard.

In the example we create a CLIENT span and inject the traceparent header into an HTTP call. Adapt this to your HTTP library. The same applies to messaging systems.

<?php
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;

$tracer = Globals::tracerProvider()->getTracer('php-monolith');
$clientSpan = $tracer->spanBuilder('http.client:payment/charge')
    ->setSpanKind(SpanKind::KIND_CLIENT)
    ->startSpan();
$scope = $clientSpan->activate();

try {
    // Injeta traceparent nos headers
    $headers = [];
    $propagator = TraceContextPropagator::getInstance();
    $propagator->inject($headers, new ArrayAccessGetterSetter(), Context::getCurrent());

    // Exemplo com cURL
    $ch = curl_init('https://payments.example.com/charge');
    $curlHeaders = [];
    foreach ($headers as $k => $v) {
        $curlHeaders[] = $k . ': ' . $v;
    }
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => $curlHeaders,
    ]);
    $resp = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE) ?: 0;
    $clientSpan->setAttribute('http.status_code', $status);
    curl_close($ch);
} finally {
    $scope->detach();
    $clientSpan->end();
}

Sampling in production: safe strategies and tuning

Sampling controls cost and volume. Use parent-based samplers to keep cohesion: if the root was sampled, child spans follow. In production, combine a ratio with rules that always collect errors.

Avoid abrupt changes. Tune the ratio per environment and time window. To find rare incidents temporarily increase sampling via an environment variable.

Strategy Type When to use Config (example)
always_on head-based Diagnostics in staging or testing OTEL_TRACES_SAMPLER=always_on
always_off head-based Temporarily turn off OTEL_TRACES_SAMPLER=always_off
traceidratio head-based Simple global control OTEL_TRACES_SAMPLER=traceidratio; OTEL_TRACES_SAMPLER_ARG=0.1
parentbased_traceidratio head-based Keep child spans consistent OTEL_TRACES_SAMPLER=parentbased_traceidratio
# Variáveis úteis por ambiente
export OTEL_SERVICE_NAME=php-monolith
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.3.0"

Correlating trace_id with structured logs

Include trace_id and span_id in logs. This lets you jump from an error in the log to the corresponding trace. Prefer JSON logs with stable fields.

If you already use structured logs, add the context IDs to the logger. See the full guide to structured logs and correlation to standardize the format for production.

<?php
use OpenTelemetry\API\Trace\Span;

$span = Span::getCurrent();
$ctx = $span->getContext();
$traceId = method_exists($ctx, 'getTraceId') ? $ctx->getTraceId() : null;
$spanId  = method_exists($ctx, 'getSpanId')  ? $ctx->getSpanId()  : null;

$log = [
    'level' => 'error',
    'message' => 'Pedido não encontrado',
    'trace_id' => $traceId,
    'span_id' => $spanId,
    'order_id' => $orderId ?? null,
];
error_log(json_encode($log));

How to validate instrumentation without a traces backend

To validate locally, start with always_on sampling in a test environment. Generate requests and verify the traceparent header propagates in outbound calls.

You can also temporarily expose the trace_id in the HTTP response in test environments. Remove this before go‑live. The goal is to confirm chaining and attributes.

  • Enable always_on only in staging.
  • Generate realistic load with simple scripts.
  • Confirm the presence of traceparent in HTTP outputs.
  • Review attributes to avoid PII in spans.
<?php
// Em ambiente de teste, retorne o trace_id no header para depuração
use OpenTelemetry\API\Trace\Span;
$ctx = Span::getCurrent()->getContext();
if (method_exists($ctx, 'getTraceId')) {
    header('x-trace-id: ' . $ctx->getTraceId());
}

Frequently asked questions

Do monoliths really benefit from distributed tracing?

Yes. Even without microservices, monoliths have layers, API calls, jobs and databases. Tracing maps the full request path, revealing bottlenecks and errors that isolated logs don’t show.

Which exporter should I use in PHP for OpenTelemetry?

Prefer OTLP. Use HTTP/protobuf when the network has proxies or inspection, and gRPC for long-lived connections and lower overhead. In both cases, send to an OpenTelemetry Collector when possible.

What is the performance impact of tracing in PHP?

It depends on sampling, volume and span processor. Batch processing with a parent-based ratio typically has low impact. Avoid collecting sensitive attributes and unnecessary spans. Always measure in staging before go‑live.

How do I avoid capturing sensitive data in spans?

Define attribute conventions. Don’t record PII. Mask or hash identifiers when needed. Review events and attributes before production and during periodic audits.

How to correlate logs with traces in PHP?

Include trace_id and span_id in logs. In JSON logs, use stable fields. That way you can jump from the log to the trace and back. A consistent logging pattern greatly speeds incident analysis.

Can I start with 100% sampling?

Yes, in test environments. In production, use parentbased_traceidratio with a controlled ratio. Temporarily raise sampling to investigate incidents, then return to the safe value.

Need help applying this to your monolith?

If you want to speed up a tracing rollout with OpenTelemetry in your PHP app, review instrumentation and standardize attributes, let’s discuss your context.

Discuss your setup

See also