Skip to content

OpenTelemetry tracing

Propulsion\Observability\OpenTelemetryQueryObserver emits one span per statement and exports it over OTLP/HTTP. It is a query observer like any other, with one difference that matters in practice: it is the only one you can turn on entirely from configuration, with no application code at all.

Three steps: install the OpenTelemetry packages, install a PSR-18 HTTP client, add a telemetry block to the runtime configuration.

None of these are hard dependencies of Propulsion (see Why these aren’t required), so install them yourself:

Terminal window
composer require open-telemetry/api open-telemetry/sdk \
open-telemetry/exporter-otlp open-telemetry/sem-conv

The OTLP/HTTP exporter needs one, and Propulsion resolves whatever is installed via php-http/discovery rather than pinning a concrete implementation for every consumer. Any provider works — if your application already has one (Guzzle, symfony/http-client, php-http/curl-client, or one pulled in by some other dependency), you are done. Otherwise:

Terminal window
composer require guzzlehttp/guzzle

A new optional top-level section in the runtime configuration array, alongside datasources, cache and connection:

runtime-conf.php
<?php
return [
'datasources' => [
'default' => 'bookstore',
'bookstore' => [
'adapter' => 'pgsql',
'connection' => ['dsn' => 'pgsql:host=localhost;dbname=bookstore'],
],
],
'telemetry' => [
'enabled' => true,
'service_name' => 'bookstore-api',
'exporter' => [
'endpoint' => 'http://otel-collector:4318/v1/traces',
],
],
];

That is the whole setup — Propulsion::init() picks it up and registers the observer. Nothing else in your application changes.

If telemetry.enabled is true and either the OpenTelemetry packages or a PSR-18 client are missing, the first query throws a PropulsionException naming exactly what to composer require. A configuration flag that silently shipped nothing would be a worse failure than an exception pointing at the fix.

An absent telemetry key, or 'enabled' => false, means no OpenTelemetry class is referenced at all — you do not need the packages installed. And with it on, the exporter, batch processor and HTTP client behind it are built lazily, on the first statement: a process that enables telemetry but never queries anything never pays for any of it. Console commands and health checks that touch no database are free.

KeyDefaultEffect
telemetry.enabledfalseMaster gate. When false, nothing is built and nothing is registered.
telemetry.service_name'propulsion'The service.name resource attribute — how your backend labels this process. Set it; the default is deliberately generic.
telemetry.exporter.endpointThe OTLP/HTTP traces endpoint, path included. Required when enabled is true, and validated as a URL.
telemetry.exporter.protocol'http/protobuf'http/protobuf or http/json. Anything else is rejected at parse time.
telemetry.exporter.headers[]Extra HTTP headers on every export request — a vendor API key, for instance. A map of string to string.
telemetry.exporter.timeout10Export request timeout, in seconds. Must be at least 1.
telemetry.sampler.ratio1.0TraceIdRatioBased sampling: 1.0 keeps everything, 0.0 nothing, 0.05 one trace in twenty. Outside 0.01.0 is rejected.
telemetry.record_statement_texttrueWhether db.query.text is attached to spans — see the PII note.

Like cache and connection, this section is strictly validated: an unknown key or a wrong value type throws a PropulsionException at parse time. That is the point — a silently-ignored 'enpoint' => '...' typo would leave the feature looking on while shipping nothing, and nothing would ever say so.

Every key at once, as you might configure it for production behind a vendor endpoint:

'telemetry' => [
'enabled' => true,
'service_name' => 'bookstore-api',
'exporter' => [
'endpoint' => 'https://otlp.example-vendor.com/v1/traces',
'protocol' => 'http/protobuf',
'headers' => [
'authorization' => 'Bearer ' . getenv('OTLP_TOKEN'),
'x-tenant' => 'bookstore',
],
'timeout' => 5,
],
'sampler' => [
'ratio' => 0.05, // 5% of traces — database spans are high-volume
],
'record_statement_text' => true,
],

The runtime configuration is plain PHP, so branch on your environment directly rather than maintaining two files:

$isProduction = getenv('APP_ENV') === 'production';
return [
'datasources' => [ /* ... */ ],
'telemetry' => [
'enabled' => getenv('OTEL_ENABLED') === '1',
'service_name' => 'bookstore-api',
'exporter' => [
// A local collector in development; the vendor in production.
'endpoint' => $isProduction
? 'https://otlp.example-vendor.com/v1/traces'
: 'http://localhost:4318/v1/traces',
'headers' => $isProduction
? ['authorization' => 'Bearer ' . getenv('OTLP_TOKEN')]
: [],
],
// See everything locally; sample hard in production.
'sampler' => ['ratio' => $isProduction ? 0.05 : 1.0],
// Raw SQL text stays on the machine it was written on.
'record_statement_text' => !$isProduction,
],
];

For a local collector to point that at, the OpenTelemetry Collector with an OTLP receiver on 4318 is the usual choice; if you are running Propulsion under Quiote, the quioteframework/telemetry-dashboard package’s telemetry:dashboard command receives OTLP directly and needs no collector at all.

Quiote has its own telemetry for request, route, action and view spans, configured through telemetry.* settings — a separate system from this one, which traces statements. Both export to the same place and interleave correctly in a backend, so a slow action span shows the queries it made nested inside it. Configure each on its own side: Quiote’s in Config/, Propulsion’s in the runtime configuration file the propulsion database’s config parameter points at.

One CLIENT-kind span per statement, named after its leading SQL verb — SELECT, INSERT, UPDATE, DELETE, SAVEPOINT, and so on. Attributes come from open-telemetry/sem-conv’s own constants rather than hand-typed strings, so what changes them is a semantic-conventions version bump, not silent drift:

Attribute
db.system.namemapped from the connection’s PDO::ATTR_DRIVER_NAMEmysql, postgresql, sqlite, oracle.db, microsoft.sql_server, or other_sql for a driver not in that list
db.query.textthe statement text, as sent — gated by record_statement_text
db.response.returned_rowswhen QueryExecution::getRowCount() is not null, so never for a SELECT — why

A failed statement gets recordException(), an error.type attribute holding the exception class, and an Error span status. The span still ends either way, and the exception still reaches the caller unchanged, exactly like every other observer.

Spans cover the ORM’s own bookkeeping too — liveness pings, savepoints, metadata queries — because they are real statements against a real server and hiding them would make a trace lie about what the request did. ->source distinguishes them if you want to filter, but that means writing an observer rather than configuring this one.

Most Propulsion traffic is a prepared statement with bound placeholders, so the text rarely carries a literal value — the values live in ->boundParams, which this observer does not export. exec()/query() traffic can carry literals, though: the ORM’s own bookkeeping, plus any raw SQL your application runs that way. Turn record_statement_text off if that is a compliance concern for those paths.

Why db.system.name uses the incubating value set

Section titled “Why db.system.name uses the incubating value set”

The stable subset of that attribute only names four database systems — MySQL, MariaDB, PostgreSQL and SQL Server — which would silently drop SQLite and Oracle, two of the five platforms Propulsion’s own test matrix covers. The incubating set is the actual current spec surface for the rest.

Propulsion::flushTelemetry() force-flushes whatever spans have buffered, without waiting for the exporter’s batch timer.

You normally don’t call it: the factory registers a register_shutdown_function() that does, which is the right granularity under ordinary PHP-FPM or CLI, where a shutdown function runs at the end of every request. Under a true async worker runtime (FrankenPHP worker mode), the whole worker script is one long-lived process, so that shutdown function fires once at worker exit rather than per served request. Flush at the request boundary yourself there:

$handler = function ($request) {
try {
return $app->handle($request);
} finally {
Propulsion::getSession()->reset(); // request-scoped state
Propulsion::flushTelemetry(); // buffered spans
}
};

Same contract as resetting a QueryStatsObserver per request: what is process-scoped stays process-scoped, and the request boundary is yours to mark.

If your application already builds a TracerProviderInterface — its own, or one shared with other instrumentation — register that instead and skip telemetry configuration entirely:

Propulsion::setTelemetryTracerProvider($tracerProvider);

It takes effect immediately, works independently of telemetry.enabled, and always wins over a configuration-built provider. Propulsion::setTelemetryHttpClient() is the narrower version of the same idea — supply a specific PSR-18 client, and let the configuration-driven path build everything else on top of it:

Propulsion::setTelemetryHttpClient($myConfiguredGuzzleClient);

Or wire the observer up by hand, which is all the configuration path does for you anyway. The constructor takes a callable returning a tracer, so the tracer is resolved on first use rather than at registration:

use Propulsion\Observability\OpenTelemetryQueryObserver;
Propulsion::addQueryObserver(new OpenTelemetryQueryObserver(
fn () => $tracerProvider->getTracer('bookstore-api'),
));

Neither setter survives Propulsion::setConfiguration() — the same contract Propulsion::setQueryCachePool() already has. If you re-configure, call them again afterwards.

open-telemetry/api — what the observer itself binds against — is interfaces and no-op fallbacks, negligible weight. The heavy packages (open-telemetry/sdk, open-telemetry/exporter-otlp) stay optional, listed in require-dev and suggest, so an application that never sets telemetry.enabled never needs them installed. Nothing in the codebase references those classes until the first query actually runs with telemetry active.

This is not a replacement for opentelemetry-auto-pdo

Section titled “This is not a replacement for opentelemetry-auto-pdo”

open-telemetry/opentelemetry-auto-pdo wraps raw PDO/PDOStatement calls with zero code changes, using the opentelemetry native PHP extension’s engine-level method hooking — the same mechanism Datadog’s ddtrace and New Relic’s agent use. That includes persistent connections, which this observer structurally cannot reach (PDO refuses a custom statement class under PDO::ATTR_PERSISTENT; see connection resilience). If you already run it, or an APM agent, you likely have database spans today.

What it cannot give you is ORM context: it sees raw SQL text and PDO-level facts only, with no notion of QueryExecution::source, so no way to tell the ORM’s own bookkeeping from real application queries. It also requires a native extension, which some hosts do not allow; OpenTelemetryQueryObserver is pure PHP over Composer. The two are complementary — run both if you want persistent-connection coverage and ORM-aware spans.