# 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.

Source: https://openobserve.ai/opentelemetry/php/
Published: 2026-07-08
Language: PHP

---

## 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](/blog/what-is-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:

| Signal  | Status |
| ------- | ------ |
| Traces  | Stable |
| Metrics | Stable |
| Logs    | Stable |

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](https://cloud.openobserve.ai) or self-hosted:

```bash
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:

```bash
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

```bash
pecl install opentelemetry
```

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

```ini
[opentelemetry]
extension=opentelemetry.so
```

Verify it is loaded:

```bash
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:

```bash
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:

```ini
; /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
<?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
<?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
<?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
<?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 `LogRecord`s by hand:

```bash
composer require open-telemetry/opentelemetry-logger-monolog
```

```php
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](/blog/otel-exporters-introduction/).)

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:

```bash
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:**

```bash
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:**

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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.
