ez-php / otel
OpenTelemetry-style spans and OTLP/HTTP JSON export for the ez-php framework, without a vendor SDK
Requires
- php: ^8.5
- ez-php/contracts: ^2.7.0
- ez-php/http: ^2.7.0
Requires (Dev)
- ez-php/docker: ^2.7.0
- ez-php/framework: ^2.7.0
- ez-php/http-client: ^2.7.1
- ez-php/logging: ^2.7.0
- ez-php/queue: ^2.7.0
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
Suggests
- ez-php/framework: Needed to bind OtelServiceProvider and register OtelMiddleware in the request pipeline
- ez-php/http-client: Needed for Instrumentation\TracingTransport (client spans + traceparent propagation)
- ez-php/logging: Needed for Instrumentation\TraceContextLogger (trace_id/span_id on every log record) and to correlate trace IDs with RequestContextMiddleware's request_id via TraceId::fromSeed()
- ez-php/queue: Instrumentation\TracingQueue works with any ez-php/contracts QueueInterface; ez-php/queue's drivers and Worker are the usual counterpart
Provides
None
Conflicts
None
Replaces
None
README
OpenTelemetry spans and OTLP/HTTP JSON export for the ez-php framework — no vendor SDK, no protobuf, stdlib only.
Installation
composer require ez-php/otel
Register the provider in provider/modules.php:
EzPhp\Otel\OtelServiceProvider::class,
Configuration (config/otel.php)
| Key | Env var | Default | Description |
|---|---|---|---|
otel.exporter |
OTEL_EXPORTER |
otlp if an endpoint is set, else discard |
otlp, memory, or anything else to discard spans |
otel.endpoint |
OTEL_EXPORTER_OTLP_ENDPOINT |
null |
Full OTLP/HTTP traces URL, e.g. http://localhost:4318/v1/traces |
otel.service_name |
OTEL_SERVICE_NAME |
ez-php-app |
service.name resource attribute |
Usage
Trace every HTTP request by adding the middleware:
$app->middleware(EzPhp\Otel\OtelMiddleware::class);
It creates a SERVER span per request, continues an incoming W3C traceparent header when present, and marks the span as an error on 5xx responses or exceptions.
To share one trace ID between logs and spans, construct the middleware with a seed resolver that returns the same correlation ID your logger uses (for example the request_id from ez-php/logging's RequestContextMiddleware):
new OtelMiddleware($tracer, static fn (): ?string => $currentRequestId);
Custom spans:
use EzPhp\Otel\Otel; use EzPhp\Otel\SpanKind; $tracer = Otel::tracer(); $span = $tracer->startSpan('charge-card', SpanKind::Client); $span->setAttribute('payment.provider', 'stripe'); // ... work ... $tracer->endSpan($span);
Limits
With the OTLP exporter, spans are batched (otel.batch_size, default 512 per request; the remainder
is sent at the end of the PHP request) and optionally head-sampled (otel.sample_ratio, e.g. 0.1 keeps
10 % of traces — decided per trace ID, so a trace is never cut in half). Both are plain exporter
decorators you can also compose by hand:
use EzPhp\Otel\Exporter\BatchingSpanExporter; use EzPhp\Otel\Exporter\TraceIdRatioSampler; $exporter = new TraceIdRatioSampler(new BatchingSpanExporter(new OtlpHttpExporter($url, 'svc')), ratio: 0.1);
Outgoing HTTP and database spans
Wrap the HTTP client transport and the database connection; their spans become children of the
request's SERVER span (OtelMiddleware marks it active), and outgoing requests carry a traceparent
header so the called service joins the trace:
use EzPhp\Otel\Instrumentation\TracingDatabase; use EzPhp\Otel\Instrumentation\TracingTransport; $client = new HttpClient(new TracingTransport(new CurlTransport(), $tracer)); // requires ez-php/http-client $db = new TracingDatabase($database, $tracer); // any DatabaseInterface
Database spans carry the SQL statement (db.statement), never the bindings. Streaming HTTP requests are
not traced.
To find the log lines of a trace, wrap the ez-php/logging logger (requires ez-php/logging); every
record written while a span is active gets its trace_id and span_id in the context:
use EzPhp\Otel\Instrumentation\TraceContextLogger; $logger = new TraceContextLogger($logger, $tracer); $logger->info('Order placed', ['order' => 7]); // context: order, trace_id, span_id
Queued jobs
Wrap the queue driver so a job's spans join the trace of the request that pushed it. push() stamps the
active span's traceparent onto the stored job (as a TracedJob envelope); pop() unwraps it, starts a
CONSUMER span (<queue> process) parented to that context and makes it active while the Worker runs the
original job. The span ends at the next queue call — Error when the job is retried or fails for good:
use EzPhp\Otel\Instrumentation\TracingQueue; $queue = new TracingQueue($driver, $tracer); // any QueueInterface, e.g. ez-php/queue's DatabaseDriver $queue = new UniqueQueue($queue, $locks); // optional: keep UniqueQueue *outside* $worker = new Worker($queue); $worker->work('default', maxJobs: 100); $queue->finish(); // end the last job's span
Use the same decorated queue for pushing (web) and popping (worker). TracingQueue does not implement
FailedJobRepositoryInterface — resolve queue:failed against the driver. Jobs implementing ez-php/queue's
JsonPayloadJobInterface keep their JSON storage and are not wrapped, so their span starts a new trace.
A long-running worker should call $batching->flush() after each job. There is no tail sampling, no metrics
signal and no OTLP log export (logs only carry the trace context).