crystoline/xpsr-telemetry

Distributed tracing for XPSR applications, on the OpenTelemetry PHP SDK. A request through the application shows up in Jaeger, Grafana Tempo, Honeycomb, Datadog, or any other OTLP collector as one trace: the request, its database queries, the HTTP calls it made, the jobs it queued (even when a worker runs them later), with the console commands and scheduled tasks of the application as traces of their own. Log lines carry the trace and span IDs, so a log line, a trace, and a support ticket meet.

composer require crystoline/xpsr-telemetry

Composer asks whether to allow the tbachert/spi plugin the SDK ships with. Answer no: XPSR builds the SDK's components itself and needs none of the plugin's autoloading. To stop the question, add "tbachert/spi": false under config.allow-plugins in composer.json.

Add the provider to app.registry in config/app.php, after the providers of the packages it traces (database, HTTP client, queue, scheduler, Monolog), and the middleware first in app.middleware, so every other middleware is inside the request's span:

'registry' => [
    // ...
    Crystoline\Xpsr\Telemetry\TelemetryServiceRegistry::class,
],
'middleware' => [
    Crystoline\Xpsr\Telemetry\Http\TraceRequestsMiddleware::class,
    // ...
],

Installing the package changes nothing until telemetry is turned on. Then the only setting most collectors need is where they listen:

OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318

Configuration

Set environment variables, or override them in config/telemetry.php:

Setting Environment Default Values
enabled OTEL_ENABLED false true turns tracing on.
service_name OTEL_SERVICE_NAME app.name The service.name every trace carries.
service_version none The service.version resource attribute, such as the release.
environment app.env The deployment.environment.name resource attribute.
exporter OTEL_TRACES_EXPORTER otlp otlp: OTLP over HTTP to the collector. console: JSON on standard output, for development. memory: kept for tests. none: dropped.
endpoint OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4318 The collector's OTLP/HTTP URL. /v1/traces is appended to a base URL.
headers OTEL_EXPORTER_OTLP_HEADERS none Headers for the collector, such as an API key: ['x-honeycomb-team' => '...'], or x-honeycomb-team=... in the variable.
protocol OTEL_EXPORTER_OTLP_PROTOCOL http/protobuf Or http/json.
timeout 10 Seconds to wait for the collector.
client the default client A client of config/http_client.php for the export, for a proxy or certificates.
sample OTEL_TRACES_SAMPLER_ARG 1 The share of new traces kept, 0 to 1. A request that continues an incoming trace follows that trace's decision.
batch true Export spans in batches when the request or command ends, so a request never waits on the collector. false exports each span as it ends, for development.
attributes none More resource attributes: ['service.namespace' => 'shop'].
trace all on Which instrumentations run: ['queries' => false] keeps database queries out. The names are requests, queries, http_client, jobs, commands, and tasks.
// config/telemetry.php
return [
    'enabled' => env('APP_ENV') === 'production',
    'service_name' => 'shop-api',
    'service_version' => env('APP_RELEASE'),
    'endpoint' => 'https://api.honeycomb.io',
    'headers' => ['x-honeycomb-team' => env('HONEYCOMB_KEY')],
    'sample' => 0.1,
];

Invalid values fail when telemetry is first used, with the setting's name. The configuration wins over the environment variables.

What is traced

Each span carries the attributes of the OpenTelemetry semantic conventions, named in Crystoline\Xpsr\Telemetry\Attributes, plus a few of XPSR's own under xpsr..

Span Kind Name Attributes
A request server GET /payments/{id}: the method and the route's path, or the method alone when no route matched http.request.method, http.route, http.response.status_code, url.path, url.query, url.scheme, server.address, server.port, client.address (respecting app.trusted_proxies), user_agent.original, network.protocol.version, xpsr.route.name, and xpsr.request_id with the Monolog package. An incoming traceparent header makes the span part of the caller's trace.
A database query client SELECT payments: the operation and the first table db.system.name, db.operation.name, db.collection.name, db.query.text (the SQL with its placeholders; parameter values are never recorded), xpsr.db.connection. From the QueryExecuted event of crystoline/xpsr-database, for DBAL and Eloquent alike.
An outgoing HTTP request client POST: the method http.request.method, url.full (without credentials), server.address, server.port, http.response.status_code. The request carries traceparent, so a service on XPSR, or on any OpenTelemetry SDK, continues the trace. Through the default and the named clients of crystoline/xpsr-http-client; Symfony's HttpClientInterface is not traced.
Queuing a job producer send default: the transport messaging.system, messaging.operation.type, messaging.destination.name, xpsr.job. The job is queued with the trace context, in a TraceContextStamp.
Running a job consumer job App\Jobs\SendReceipt The same, in the trace that queued the job, whether a worker runs it or sync:// runs it at once. A job queued without a context starts a trace of its own rather than joining the worker's.
A console command internal command migrate xpsr.command, xpsr.command.exit_code. Not for queue:work, schedule:work, and serve, which run as long as the process: their jobs and tasks have their own traces.
A scheduled task internal task reports:daily: the task's description xpsr.task, xpsr.task.kind, xpsr.task.status. A command task's command gets its span inside; a job task's job gets its own trace when a worker runs it.

A span fails, with the exception recorded as an event, when a request answers 5xx or an exception reaches the kernel, when an outgoing request answers 4xx or 5xx or cannot be sent, when a job throws, when a command throws or exits with a code other than 0, and when a task fails. A 4xx answer of the application is not a failure, and the span of a request that matched no route stays named by its method.

Your own spans

Crystoline\Xpsr\Telemetry\Tracer wraps the SDK's tracer, so application code does not depend on OpenTelemetry's API. A span is a child of the current one (the request, job, or command), is current while the closure runs, records an exception the closure throws and rethrows it, and ends when the closure returns:

use Crystoline\Xpsr\Telemetry\Span;
use Crystoline\Xpsr\Telemetry\Tracer;

final class ChargeController
{
    public function __construct(private readonly Tracer $tracer, private readonly Gateway $gateway) {}

    public function capture(ServerRequestInterface $request): ResponseInterface
    {
        $charge = $this->tracer->span('charge.capture', function (Span $span) use ($request): Charge {
            $span->setAttribute('charge.id', $request->getAttribute('id'));
            $charge = $this->gateway->capture($request->getAttribute('id'));
            $span->addEvent('gateway.answered', ['reference' => $charge->reference]);

            return $charge;
        });
        // ...
    }
}

start($name, $attributes) gives a span to end() by hand; current() is the span in progress, with traceId() and spanId() to show a user or write somewhere; inject($headers) writes the trace context into an array of headers for a message that continues the trace elsewhere, and extract($headers) reads one back. Attributes are strings, numbers, booleans, or lists of one of them; enums and Stringable objects become strings. With telemetry off, closures still run and spans do nothing.

The SDK's own API works too: the tracer provider is registered with OpenTelemetry\API\Globals, so a library instrumented with OpenTelemetry joins the same traces.

Logs

With crystoline/xpsr-log-monolog, every log line written inside a span carries trace_id and span_id in its extra data, next to request_id. Search the collector for the trace ID of a log line, or the logs for the trace ID of a slow span. List the telemetry provider after the Monolog one.

Exporting

Spans are kept until the request or command ends, then sent in one batch from a shutdown function, so a request never waits on the collector; a worker sends a batch every few seconds or every 512 spans. Telemetry::flush() sends what is finished now. The export goes through the HTTP client package's client (or the one telemetry.client names), without being traced itself.

The console exporter prints each span as JSON on standard output: OTEL_TRACES_EXPORTER=console php xpsr migrate shows what a command would send. The SDK's other OTEL_* variables are not read: XPSR configures the SDK from the settings above.

Testing

Turn telemetry on with the memory exporter in the test environment, in phpunit.xml:

<env name="OTEL_ENABLED" value="true"/>
<env name="OTEL_TRACES_EXPORTER" value="memory"/>

Crystoline\Xpsr\Telemetry\Testing\FakeTelemetry then has the spans the application finished:

use Crystoline\Xpsr\Telemetry\Testing\FakeTelemetry;
use Crystoline\Xpsr\Telemetry\Testing\FinishedSpan;

$this->postJson('/payments', ['amount' => 500]);

$spans = $this->application()->get(FakeTelemetry::class);
$request = $spans->assertSpan('POST /payments', static fn(FinishedSpan $span): bool => $span->attribute('http.response.status_code') === 201);
$spans->assertSpan('job App\Jobs\SendReceipt', static fn(FinishedSpan $span): bool => $span->traceId() === $request->traceId());
$spans->assertSpan('charge.capture', null, 1);                   // exactly once
$spans->assertNoSpan('SELECT payments', static fn(FinishedSpan $span): bool => $span->isError());
$spans->assertNothingTraced();

A FinishedSpan has name(), kind(), attribute($name), attributes(), isError(), error(), exception(), events(), traceId(), spanId(), parentSpanId(), isRoot(), and durationNanos(). spans() lists them in the order they ended, find($name) those with a name, and clear() forgets them. A failed assertion names the spans that finished.

Services

Service Provides
Crystoline\Xpsr\Telemetry\Tracer span(), start(), current(), traceId(), spanId(), inject(), extract(). Shared.
Crystoline\Xpsr\Telemetry\Telemetry The tracer provider: enabled(), options(), flush(), shutdown(). Shared.
Crystoline\Xpsr\Telemetry\Http\TraceRequestsMiddleware The request span; first in app.middleware.
Crystoline\Xpsr\Telemetry\Testing\FakeTelemetry The finished spans, with the memory exporter. Shared.

Not provided: metrics and OpenTelemetry logs (traces first; metrics can follow once there is a use for them), the gRPC transport (the OTLP/HTTP one serves every collector), and spans for views or cache calls.

Requires PHP 8.4+, open-telemetry/sdk 1.15+, and open-telemetry/exporter-otlp 1.4+. The database, HTTP client, queue, scheduler, and Monolog packages are optional and detected.