Upcoming Webinar:

Getting Started with OpenObserve

July 16, 2026
11:00 AM ET

OpenTelemetry for PHP: Traces, Metrics & Logs with OpenObserve

Instrument PHP with OpenTelemetry: SDK setup, the C extension for auto-instrumentation, FPM batching realities, and OTLP export to OpenObserve.

OpenTelemetry guide

Why OpenTelemetry for PHP

PHP powers a huge share of the web, yet it has historically been one of the harder languages to observe: the classic share-nothing, per-request execution model means there is no long-lived process to accumulate state in, and most APM story lines were built around proprietary agents. OpenTelemetry changes that with a vendor-neutral API, an SDK shipped as ordinary Composer packages, and a C extension that enables automatic instrumentation without touching application code.

The PHP implementation is mature. All three signals are stable:

SignalStatus
TracesStable
MetricsStable
LogsStable

That means the APIs you write against today will not break underneath you, and the OTLP wire format your PHP services emit is understood by any OTLP-compatible backend — including OpenObserve, which accepts OTLP over both HTTP and gRPC and stores traces, metrics, and logs in one place.

This guide covers the two Composer packages you actually need (open-telemetry/sdk and open-telemetry/exporter-otlp), the C extension for zero-code instrumentation, manual instrumentation for all three signals, and — critically for PHP — what the per-request FPM lifecycle does to batching and how to handle it.

Prerequisites

  • PHP 8.1 or later (a hard requirement of open-telemetry/sdk and open-telemetry/exporter-otlp)
  • Composer for dependency management
  • A PSR-18 HTTP client and PSR-17 HTTP factory — the OTLP exporter discovers an HTTP client automatically, and a factory is pulled in transitively; php-http/guzzle7-adapter is the common choice
  • An OpenObserve instance — either OpenObserve Cloud or self-hosted:
docker run -d --name openobserve \
  -v $PWD/data:/data \
  -p 5080:5080 \
  -e ZO_ROOT_USER_EMAIL="root@example.com" \
  -e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
  public.ecr.aws/zinclabs/openobserve:latest

Two optional PECL extensions are worth knowing about up front:

  • ext-protobuf — strongly recommended for production. The pure-PHP protobuf fallback works but is significantly slower at serializing OTLP payloads; the compiled extension removes that overhead from every export.
  • ext-grpc — only needed if you choose the gRPC transport instead of HTTP.

Install the core packages:

composer require \
  open-telemetry/sdk \
  open-telemetry/exporter-otlp \
  php-http/guzzle7-adapter

open-telemetry/sdk pulls in the API and context packages transitively. A useful discipline from the official docs: your application code should only depend on classes in the API package (OpenTelemetry\API\...), while SDK wiring lives in your bootstrap. That keeps instrumentation portable and makes libraries safe to instrument even when no SDK is installed.

Zero-Code (Automatic) Instrumentation

PHP has real automatic instrumentation, and it is built on two pieces:

  1. The opentelemetry C extension — provides hook(), a low-level mechanism to attach pre/post callbacks to any function or method at runtime.
  2. Contrib instrumentation packages — Composer packages (named open-telemetry/opentelemetry-auto-*) that use the extension to wrap popular libraries: PDO, HTTP clients, PSR-15/PSR-18 middleware, and many frameworks.

Install the extension

pecl install opentelemetry

Then enable it in php.ini (or a conf.d file):

[opentelemetry]
extension=opentelemetry.so

Verify it is loaded:

php --ri opentelemetry

If you deploy with Docker, run the same pecl install in your image build and remember that CLI and FPM often read different ini directories — enable the extension for the SAPI that actually serves traffic.

Enable SDK autoloading

With the extension in place, add instrumentation packages for the libraries you use, then turn on SDK autoloading via environment variables. No application code changes are required:

export OTEL_PHP_AUTOLOAD_ENABLED=true
export OTEL_SERVICE_NAME=php-checkout-service
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5080/api/default
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="

php -S localhost:8080

When OTEL_PHP_AUTOLOAD_ENABLED=true, Composer’s autoloader initializes the SDK from environment variables before your first line of code runs, and any installed auto-instrumentation packages register their hooks. Requests handled by your application produce spans automatically; your own manual spans (next section) attach to them as children.

Under PHP-FPM, environment variables are not inherited from the shell by default. Set them in the pool configuration:

; /etc/php/8.3/fpm/pool.d/www.conf
env[OTEL_PHP_AUTOLOAD_ENABLED] = true
env[OTEL_SERVICE_NAME] = php-checkout-service
env[OTEL_TRACES_EXPORTER] = otlp
env[OTEL_EXPORTER_OTLP_PROTOCOL] = http/protobuf
env[OTEL_EXPORTER_OTLP_ENDPOINT] = http://localhost:5080/api/default
env[OTEL_EXPORTER_OTLP_HEADERS] = Authorization=Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=

Manual Instrumentation

Manual instrumentation needs no C extension — just the Composer packages installed earlier. The examples below configure the SDK explicitly in code so you can see every moving part; in production you can equally let the autoloader build providers from environment variables and fetch them via OpenTelemetry\API\Globals.

All three examples share a resource — the identity attached to every span, metric, and log record:

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

use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
use OpenTelemetry\SemConv\Attributes\ServiceAttributes;

$resource = ResourceInfoFactory::defaultResource()->merge(
    ResourceInfo::create(Attributes::create([
        ServiceAttributes::SERVICE_NAME => 'php-checkout-service',
        'service.version' => '1.4.2',
        'deployment.environment' => 'production',
    ]))
);

Traces

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

use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor;
use OpenTelemetry\SDK\Trace\TracerProvider;

$transport = (new OtlpHttpTransportFactory())->create(
    'http://localhost:5080/api/default/v1/traces',
    'application/x-protobuf',
    ['Authorization' => 'Basic ' . base64_encode('root@example.com:Complexpass#123')],
);

$tracerProvider = TracerProvider::builder()
    ->setResource($resource)
    ->addSpanProcessor(
        BatchSpanProcessor::builder(new SpanExporter($transport))->build()
    )
    ->build();

$tracer = $tracerProvider->getTracer('app.checkout');

$root = $tracer->spanBuilder('POST /checkout')
    ->setSpanKind(SpanKind::KIND_SERVER)
    ->startSpan();
$scope = $root->activate();

try {
    $root->setAttribute('http.request.method', 'POST');
    $root->setAttribute('order.id', 'ord_9f2c');

    // Child spans parent to the currently active span automatically
    $child = $tracer->spanBuilder('charge-payment')
        ->setSpanKind(SpanKind::KIND_CLIENT)
        ->startSpan();
    try {
        usleep(50_000); // call your payment gateway here
        $child->addEvent('payment.authorized', ['amount' => 4999]);
    } finally {
        $child->end();
    }
} catch (\Throwable $e) {
    $root->recordException($e);
    $root->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
    throw $e;
} finally {
    $scope->detach();
    $root->end();
}

$tracerProvider->shutdown(); // flushes the batch processor

The activate()/detach() pair is what makes context propagation work: while a span’s scope is active, new spans and outgoing HTTP calls (via instrumented clients) become its children. Always detach in a finally block — a leaked scope corrupts the context for the rest of the request.

Metrics

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

use OpenTelemetry\Contrib\Otlp\MetricExporter;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\SDK\Metrics\MeterProvider;
use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader;

$transport = (new OtlpHttpTransportFactory())->create(
    'http://localhost:5080/api/default/v1/metrics',
    'application/x-protobuf',
    ['Authorization' => 'Basic ' . base64_encode('root@example.com:Complexpass#123')],
);

$reader = new ExportingReader(new MetricExporter($transport));

$meterProvider = MeterProvider::builder()
    ->setResource($resource)
    ->addReader($reader)
    ->build();

$meter = $meterProvider->getMeter('app.checkout');

$ordersProcessed = $meter->createCounter(
    'orders_processed',
    '{order}',
    'Number of orders processed'
);
$checkoutDuration = $meter->createHistogram(
    'checkout_duration',
    's',
    'End-to-end checkout processing time'
);

$start = microtime(true);
// ... handle the order ...
$ordersProcessed->add(1, ['payment.method' => 'card']);
$checkoutDuration->record(microtime(true) - $start, ['payment.method' => 'card']);

$meterProvider->shutdown(); // collects and exports pending metrics

Because a classic PHP process only lives for one request, there is no background timer to collect metrics on an interval — export happens when you call forceFlush()/shutdown(). Each request effectively emits one metrics snapshot; OpenObserve aggregates the delta data points across requests. In long-running PHP processes (queue workers, Swoole, RoadRunner, FrankenPHP worker mode), call forceFlush() on a timer or every N jobs instead.

Logs

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

use OpenTelemetry\API\Common\Time\Clock;
use OpenTelemetry\API\Logs\LogRecord;
use OpenTelemetry\API\Logs\Severity;
use OpenTelemetry\Contrib\Otlp\LogsExporter;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\SDK\Logs\LoggerProvider;
use OpenTelemetry\SDK\Logs\Processor\BatchLogRecordProcessor;

$transport = (new OtlpHttpTransportFactory())->create(
    'http://localhost:5080/api/default/v1/logs',
    'application/x-protobuf',
    ['Authorization' => 'Basic ' . base64_encode('root@example.com:Complexpass#123')],
);

$loggerProvider = LoggerProvider::builder()
    ->setResource($resource)
    ->addLogRecordProcessor(
        new BatchLogRecordProcessor(new LogsExporter($transport), Clock::getDefault())
    )
    ->build();

$logger = $loggerProvider->getLogger('app.checkout');

$logger->logRecordBuilder()
    ->setSeverityNumber(Severity::INFO)
    ->setSeverityText('INFO')
    ->setAttributes(['order.id' => 'ord_9f2c', 'payment.method' => 'card'])
    ->setBody('order processed')
    ->emit();

$loggerProvider->shutdown();

If your application already logs through Monolog (most do), bridge it instead of emitting LogRecords by hand:

composer require open-telemetry/opentelemetry-logger-monolog
use Monolog\Logger;
use OpenTelemetry\Contrib\Logs\Monolog\Handler;
use Psr\Log\LogLevel;

$handler = new Handler($loggerProvider, LogLevel::INFO);
$monolog = new Logger('app', [$handler]);

$monolog->info('order processed', ['order.id' => 'ord_9f2c']);

Log records emitted while a span is active automatically carry its trace and span IDs, which is what lets OpenObserve jump from a trace to its correlated logs.

Sending Data to OpenObserve

Everything above hardcoded endpoints for clarity. In real deployments, configure the OTLP exporter through the standard environment variables so the same build runs against any environment. (For a deeper look at how OTLP exporters behave, see our beginner’s guide to OTLP exporters.)

OpenObserve exposes OTLP over HTTP at /api/<organization>/v1/traces, /api/<organization>/v1/metrics, and /api/<organization>/v1/logs, and OTLP over gRPC on port 5081 (self-hosted; configurable via ZO_GRPC_PORT). For PHP, OTLP over HTTP with protobuf encoding is the right default — no extra extensions, works through any proxy.

Authentication is an Authorization: Basic <base64(email:token)> header. Generate the value with:

echo -n 'root@example.com:Complexpass#123' | base64
#=> cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=

For OpenObserve Cloud, copy the ready-made endpoint and Authorization header from the ingestion/data sources page in the UI rather than base64-encoding your login password.

Self-hosted:

export OTEL_SERVICE_NAME=php-checkout-service
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5080/api/default
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="

OpenObserve Cloud:

export OTEL_SERVICE_NAME=php-checkout-service
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=https://api.openobserve.ai/api/YOUR_ORG_NAME
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic YOUR_BASE64_TOKEN"

gRPC transport (optional)

If you run a long-running PHP runtime and want gRPC instead, install the transport package and extension:

composer require open-telemetry/transport-grpc
pecl install grpc

Then point the exporter at OpenObserve’s gRPC port (5081 on self-hosted, configurable with ZO_GRPC_PORT) with OTEL_EXPORTER_OTLP_PROTOCOL=grpc. gRPC ingestion to OpenObserve additionally expects an organization header and a stream-name header alongside Authorization. For typical FPM deployments the gRPC extension adds build complexity for little gain — persistent connections only pay off when the process persists — so treat HTTP/protobuf as the default and gRPC as an optimization for workers and Swoole/RoadRunner-style servers.

Two details that cause most misconfigurations:

  1. OTEL_EXPORTER_OTLP_ENDPOINT is a base URL. The SDK appends /v1/traces, /v1/metrics, or /v1/logs per signal. So the value must include the organization segment (/api/default) and must not end with a trailing slash and must not include /v1/... itself. If you use the per-signal variables (OTEL_EXPORTER_OTLP_TRACES_ENDPOINT etc.), those take the full path including /v1/traces.
  2. The header value contains the word Basic, a space, and the token. Quote the whole variable so the shell does not split it.

Verifying Data Arrival in OpenObserve

  1. Generate some traffic — hit your endpoint a few times or run the scripts above.
  2. Open the OpenObserve UI (http://localhost:5080 self-hosted, or your cloud URL) and log in.
  3. Go to Streams. You should see streams for your ingested data — trace data lands in a traces stream (default unless you configured otherwise), and logs/metrics appear as their own stream types with document counts increasing.
  4. Go to Traces, pick your service (php-checkout-service) or search by operation name, and open a trace to see the span tree — your POST /checkout root span with charge-payment as a child, plus any auto-instrumented spans.
  5. Go to Logs, select the log stream, and confirm your records arrived with trace_id/span_id fields populated when they were emitted inside an active span.

If nothing shows up within a minute, jump to the troubleshooting section — the failure is almost always the endpoint path or the Authorization header.

Production Tips

Understand the execution model: per-request vs long-running

This is the single most important PHP-specific consideration.

Per-request (PHP-FPM, Apache mod_php): every request is a fresh process (or a recycled worker with fresh state). The SDK is initialized at the start of the request and torn down at the end. Consequences:

  • BatchSpanProcessor’s background flush interval (default 5s) rarely fires within a request’s lifetime. Instead, the SDK’s shutdown handling flushes everything at end-of-request. Batching still helps — it coalesces all spans from the request into one OTLP call instead of one per span — but you get one export per request, synchronously, during shutdown.
  • That flush keeps the FPM worker occupied after the response is done, reducing worker availability under load. Keep the export destination network-close: a same-host or sidecar OpenTelemetry Collector receiving on localhost:4318 and forwarding to OpenObserve is the standard pattern, and it also gives you retries and buffering that a dying PHP process cannot provide.
  • Install ext-protobuf. Serialization cost is paid on every single request; the native extension makes it negligible.

Long-running (CLI queue workers, Swoole, RoadRunner, FrankenPHP worker mode, ReactPHP): the process persists across requests/jobs, so batching works the way it was designed — spans accumulate and export on the timer in the background. Two obligations come with it: call $tracerProvider->shutdown() (and the metric/log equivalents) on graceful termination so the final batch is not lost, and call forceFlush() periodically in job loops so telemetry for job N doesn’t wait until job N+500.

Batch processor tuning

The batch processors honor the standard spec environment variables, so you can tune them without code changes:

export OTEL_BSP_MAX_QUEUE_SIZE=2048        # spans buffered before drops (default 2048)
export OTEL_BSP_SCHEDULE_DELAY=5000        # background flush interval, ms (default 5000)
export OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512  # spans per OTLP request (default 512)

In per-request FPM these mostly bound worst-case memory (a runaway request generating thousands of spans hits OTEL_BSP_MAX_QUEUE_SIZE and drops the excess rather than exhausting memory). In long-running processes, OTEL_BSP_SCHEDULE_DELAY is the knob that trades freshness against export frequency.

Sampling

Head sampling is configured entirely through environment variables — no code changes:

export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1

parentbased_traceidratio samples 10% of new root traces but always honors the incoming decision on propagated requests, so distributed traces stay complete. Start at 100% (always_on, the default) in staging, and dial down in production based on volume.

Resource attributes

Set service.name always — it is how you find your service in OpenObserve. Add service.version and deployment.environment so you can compare releases and filter staging noise:

export OTEL_SERVICE_NAME=php-checkout-service
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"

Common PHP pitfalls

  • Building providers per operation. Construct TracerProvider/MeterProvider/LoggerProvider once in your bootstrap, not inside controllers or loops. Each provider owns exporter connections and processor state.
  • Leaked scopes. Every $span->activate() must have a matching $scope->detach() in a finally block. In long-running runtimes a leaked scope poisons context for subsequent requests.
  • Blocking on export in hot paths. If end-of-request export latency shows up in your FPM metrics, move the network hop to a local collector; the PHP process then exports over loopback in microseconds.
  • CLI vs FPM ini split. The opentelemetry extension and environment variables must be configured for the SAPI serving traffic, not just the CLI you tested with.

Troubleshooting

Symptom: no data in OpenObserve, no errors in PHP logs. Fix: check the endpoint construction. OTEL_EXPORTER_OTLP_ENDPOINT must be the base URL including the org (http://localhost:5080/api/default) with no trailing slash — the SDK appends /v1/traces itself. A doubled path (/api/default/v1/traces/v1/traces) or a missing org segment returns 404s that batch processors swallow quietly. Enable SDK diagnostics with OTEL_LOG_LEVEL=debug to surface export errors.

Symptom: HTTP 401 Unauthorized on export. Fix: regenerate the token with echo -n 'email:token' | base64 — the -n matters; a trailing newline inside the base64 breaks authentication. Confirm the header format is Authorization=Basic <token> inside OTEL_EXPORTER_OTLP_HEADERS, with the whole value quoted in shell contexts. For OpenObserve Cloud, use the ingestion token from the UI, not your account password.

Symptom: auto-instrumentation produces no spans, but manual spans work. Fix: verify the C extension is loaded in the right SAPI (php --ri opentelemetry for CLI; check phpinfo() output via FPM for the web SAPI), and that OTEL_PHP_AUTOLOAD_ENABLED=true actually reaches the process. PHP-FPM does not pass shell environment variables by default — set them with env[...] lines in the pool config or clear_env = no.

Symptom: Class "OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory" not found or PSR-18 discovery errors. Fix: composer require open-telemetry/exporter-otlp php-http/guzzle7-adapter. The exporter lives in its own package, and the HTTP transport needs a discoverable PSR-18 client at runtime.

Symptom: FPM requests noticeably slower after enabling tracing. Fix: the per-request flush is doing a cross-network OTLP call during shutdown. Run an OpenTelemetry Collector as a sidecar/daemonset receiving on localhost:4318 and let it forward to OpenObserve asynchronously, and install ext-protobuf so payload serialization is native. Reducing the sample rate (parentbased_traceidratio) also shrinks per-request export size.

Symptom: telemetry missing from queue workers or long-running processes on deploy. Fix: the process was killed before the final batch exported. Trap SIGTERM in your worker loop and call shutdown() on all providers; between jobs, call forceFlush() periodically so at most one flush interval of data is ever at risk.

With the SDK bootstrapped once, exporters pointed at OpenObserve’s OTLP endpoints, and the FPM flush model accounted for, a PHP service emits the same standards-based traces, metrics, and logs as any Go or Java service in your stack — searchable, correlated, and queryable side by side in OpenObserve.

Frequently asked questions

Is OpenTelemetry stable for PHP?

Yes. Traces, metrics, and logs are all marked stable in the official OpenTelemetry PHP implementation. The SDK is distributed as Composer packages, and the OTLP exporter supports both HTTP/protobuf and gRPC transports.

Do I need the OpenTelemetry C extension for PHP?

Only for automatic instrumentation. The extension (installed via pecl install opentelemetry) provides the hooking mechanism that auto-instrumentation packages use to wrap functions without code changes. Manual instrumentation with the SDK works on plain PHP with no extension required.

Does OpenTelemetry work with PHP-FPM?

Yes, but each FPM request is a separate short-lived process, so batched telemetry is flushed at the end of every request rather than on a background timer. Keep the export target network-close, use the http/protobuf transport, and install the protobuf extension to keep the per-request flush cheap.

Should I use gRPC or HTTP to send OTLP data from PHP?

Use OTLP over HTTP with protobuf encoding for most PHP deployments. It requires no extra extensions and works everywhere. gRPC requires the grpc PECL extension and mainly benefits long-running PHP processes that hold persistent connections.

How do I send OpenTelemetry data from PHP to OpenObserve?

Point the OTLP HTTP exporter at your OpenObserve endpoint, for example https://api.openobserve.ai/api/YOUR_ORG/v1/traces for cloud or http://localhost:5080/api/default/v1/traces for self-hosted, and add an Authorization header with Basic followed by base64 of your email and token.

Can I use OpenTelemetry PHP with any framework?

Yes. The API and SDK are framework-agnostic, and contrib auto-instrumentation packages exist for common frameworks and libraries. Everything in this guide uses plain PHP and works the same regardless of which framework routes your requests.

Keep reading

Ship your OpenTelemetry data somewhere better

OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.

Book a DemoBook a Demo