Long-running PHP Servers
PHP frameworks such as Laravel Octane (with Swoole or RoadRunner) run PHP as a persistent server process rather than spawning a new process per request. This changes how the distro behaves in several important ways and requires specific configuration adjustments.
How traditional PHP servers work
With PHP-FPM or Apache mod_php, each HTTP request maps to its own PHP process lifecycle:
- PHP process starts → distro bootstraps (OTel SDK initialized, auto-instrumentation hooks registered)
- Request is handled → spans are created for the HTTP transaction and any instrumented calls (curl, PDO, etc.)
- Response is sent → PHP shutdown functions run → spans are flushed and exported
- PHP process exits
The distro’s transaction span (OTEL_PHP_TRANSACTION_SPAN_ENABLED) wraps
exactly one HTTP request: it starts when the request arrives (web SAPI,
$_SERVER populated) and ends when the process exits. This is the root span
that all child spans (curl, DB queries, etc.) hang from.
How long-running servers differ
With Laravel Octane (Swoole or RoadRunner), one PHP worker process handles many HTTP requests in a row without exiting between them:
- PHP process starts — worker processes start with a fully initialized distro (SDK, hooks, exporter)
- Each HTTP request is dispatched to a worker — the worker’s hooks fire and spans are created
- Response is sent — PHP shutdown functions do NOT run (the process continues)
- Worker process exits only when the server is stopped (graceful stop)
Because the worker process is a CLI process (started with
php artisan octane:start), the SAPI is always cli, not fpm-fcgi or
apache2handler. The distro uses this to distinguish:
OTEL_PHP_TRANSACTION_SPAN_ENABLED— root span for web SAPI (FPM/Apache). Not relevant here.OTEL_PHP_TRANSACTION_SPAN_ENABLED_CLI— root span for CLI processes. In a long-running server this wraps the entire server lifetime (fromoctane:starttooctane:stop), not an individual request.
| Aspect | PHP-FPM / Apache | Long-running server (Octane) |
|---|---|---|
| SAPI | fpm-fcgi / apache2handler | cli |
| Process lifetime | One process per request | One worker handles many requests |
| PHP shutdown functions | Run after every request | Run only when the worker exits |
| Distro bootstrap | Runs per request | Runs once per worker on startup |
Transaction span (_CLI) | Not applicable | Would span the entire server lifetime — disable it |
Recommended configuration
Disable the CLI transaction span
The auto root span (OTEL_PHP_TRANSACTION_SPAN_ENABLED_CLI) wraps the whole PHP
process. In a long-running server this means one span lasting until the server
shuts down, which is not useful telemetry.
export OTEL_PHP_TRANSACTION_SPAN_ENABLED_CLI=false
OTEL_PHP_TRANSACTION_SPAN_ENABLED_CLI is CLI-specific and has noeffect on web SAPI (FPM/Apache) deployments.
Disable inferred spans
Inferred spans (stack-trace sampling) are designed for traditional request-based PHP. In a long-running server the sampling runs continuously between requests, generating noise and consuming CPU.
export OTEL_PHP_INFERRED_SPANS_ENABLED=false
This is the default value, so only needed if you previously enabled inferred spans globally.
Span processor and export latency
By default the distro uses BatchSpanProcessor, which accumulates spans in
memory and exports them on a timer (default: every 5 seconds). PHP is
single-threaded, so the timer check runs only when a new span ends via onEnd()
— there is no background tick. Always use a graceful stop
(php artisan octane:stop, SIGTERM) so that workers can finish their current
request, run PHP shutdown functions, and flush the exporter before exiting. A
hard kill (SIGKILL) bypasses all of this.
With graceful stop, spans buffered in memory are flushed before the process
exits — provided the OTLP endpoint is reachable. However, BatchSpanProcessor
introduces export latency proportional to how frequently requests arrive. On
a low-traffic application, a span created at 10:00 may not appear in the
collector until 10:05 (the next request finally triggers the timer check). For
near-real-time visibility, use SimpleSpanProcessor:
export OTEL_PHP_TRACES_PROCESSOR=simple
Each span is pushed to the export queue immediately on onEnd(), regardless of
traffic volume.
The distro’s native C++ transport (HttpTransportAsync) has its own internal
queue and a persistent connection to the OTLP endpoint, so switching to simple
does not mean one HTTP request per span. The PHP layer pushes to the C++
queue synchronously (a fast in-process operation), and the C++ layer batches and
sends over the persistent connection independently.
Complete example
The same configuration applies to both Swoole and RoadRunner:
export OTEL_SERVICE_NAME="my-laravel-octane-app"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
# Long-running server adjustments
export OTEL_PHP_TRANSACTION_SPAN_ENABLED_CLI=false
export OTEL_PHP_INFERRED_SPANS_ENABLED=false
export OTEL_PHP_TRACES_PROCESSOR=simple
# Swoole
php artisan octane:start --server=swoole
# RoadRunner
php artisan octane:start --server=roadrunner
How instrumentation works per server type
Swoole
Swoole creates worker processes by forking the master PHP process. The distro bootstraps once in the master and the workers inherit the initialized state (hooks, SDK, exporter connection). Each HTTP request in a worker triggers the registered auto-instrumentation hooks (Laravel, curl, PDO, etc.) and spans are created under the worker’s TracerProvider.
RoadRunner
RoadRunner is a Go-based application server that manages PHP worker processes.
Unlike Swoole, it does not fork — it spawns each PHP worker as a separate
process. As a result, each worker bootstraps the distro independently on
startup. From the instrumentation perspective the behavior is identical: the
worker’s SAPI is cli, it handles many requests without exiting between them,
and the same configuration applies.
BatchSpanProcessor schedule delay
If you want to keep BatchSpanProcessor but reduce export latency, lower the
schedule delay:
export OTEL_BSP_SCHEDULE_DELAY=500 # ms, default is 5000
This only helps on applications with steady traffic — the timer fires on
onEnd(), so a quieter application still experiences latency proportional to
the gap between requests.
Feedback
Was this page helpful?
Thank you. Your feedback is appreciated!
Please let us know how we can improve this page. Your feedback is appreciated!