rasuvaeff / yii3-telemetry-otel
OpenTelemetry tracing backend for rasuvaeff/yii3-telemetry (Yii3)
Requires
- php: 8.3 - 8.5
- open-telemetry/api: ^1.5
- open-telemetry/context: ^1.5
- open-telemetry/exporter-otlp: ^1.4
- open-telemetry/sdk: ^1.7
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- rasuvaeff/yii3-telemetry: ^1.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- guzzlehttp/guzzle: ^7.8 || ^8.0
- infection/infection: ^0.33 || ^0.34 || ^0.35
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/di: ^1.4
- yiisoft/dummy-provider: ^1.1
- yiisoft/router: ^4.0.2
- yiisoft/yii-console: ^2.4
Suggests
- ext-protobuf: Faster OTLP protobuf serialization (pure-PHP google/protobuf is the fallback)
- guzzlehttp/guzzle: PSR-18 HTTP client for the OTLP/HTTP transport
- php-http/discovery: PSR-18/PSR-17 discovery used by the OTLP/HTTP transport
- yiisoft/router: Route-template span names ({method} {route}) via CurrentRouteNameResolver
- yiisoft/yii-console: Root span per console command (cron) via ConsoleCommandSpanListener
Provides
None
Conflicts
None
Replaces
None
README
OpenTelemetry tracing backend for rasuvaeff/yii3-telemetry.
It turns the core Tracer facade into real spans exported over OTLP to an
OpenTelemetry Collector, plus a PSR-15 root-span middleware and W3C context
propagation.
Using an AI coding assistant? llms.txt has a compact API reference you can pass as context.
Requirements
- PHP 8.3+ (64-bit)
rasuvaeff/yii3-telemetry^1.0open-telemetry/sdk^1.7,open-telemetry/exporter-otlp^1.4- A PSR-18 HTTP client for OTLP export (e.g.
guzzlehttp/guzzle)
Installation
composer require rasuvaeff/yii3-telemetry-otel guzzlehttp/guzzle
Installing this package binds the swappable TracerProviderInterface in the core
— the Tracer facade now produces exported spans. Do not also bind the
provider yourself (that is a deliberate yiisoft/config Duplicate key error).
Composer will ask to trust the php-http/discovery and tbachert/spi plugins
(transitive OpenTelemetry dependencies) — answer yes, or preconfigure them in
config.allow-plugins.
Usage
Wire it (yiisoft/config)
The package ships config/di.php, config/di-web.php, and config/params.php.
Configure the collector endpoint and service name via env vars (standard OTel
names):
OTEL_SERVICE_NAME=checkout-api OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
Add OtelMiddleware to your middleware stack (typically first) so every request
gets a SERVER root span that continues any incoming distributed trace.
Operational toggles in params.php (overridable in your app params):
| Param | Default | Meaning |
|---|---|---|
enabled |
true (honours OTEL_SDK_DISABLED=true) |
false binds the no-op NullTracerProvider — nothing is built or exported, no error-log noise from an unreachable collector |
content_type |
application/x-protobuf (honours OTEL_EXPORTER_OTLP_PROTOCOL: http/json → JSON) |
OTLP/HTTP payload encoding for a verified OTLP receiver such as the OTel Collector, Tempo, or Jaeger |
excluded_paths |
[] |
exact request paths OtelMiddleware skips — scrape/probe endpoints (/metrics, /health): Prometheus polling every few seconds floods the tracing backend with identical traces |
excluded_commands |
['queue:listen', 'queue:listen-all'] |
console commands ConsoleCommandSpanListener does not wrap in a root span — exact names, or a prefix when the entry ends with * (outbox:*); see Long-running commands |
timeout |
10.0 s (honours OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, then OTEL_EXPORTER_OTLP_TIMEOUT — milliseconds per the OTel spec, converted to seconds) |
per-request OTLP export timeout; a non-numeric or non-positive env value falls back to the default |
max_retries |
3 |
retries after the first failed export attempt (package-specific, no OTel env var); 0 disables retries |
retry_delay_ms |
100 |
initial retry back-off in milliseconds |
capture_query |
true |
url.query on the root span; values of sensitive-looking keys (password, token, api_key, …) are replaced by *** at every nesting level. Queries larger than max_query_bytes are skipped |
max_query_bytes |
4096 |
maximum query-string bytes inspected; 0 disables query capture |
capture_request_params |
false — opt in consciously (request payloads may carry personal data) |
records each query / form / top-level JSON-body parameter as http.request.param.<name>; sensitive keys masked, values truncated to 200 chars, JSON bodies over 8 KiB skipped |
max_request_params |
50 |
maximum number of request parameter attributes |
request_param_allowlist |
[] |
when non-empty, only listed parameter names are recorded |
Request data is inspected only when the sampler keeps the span. For production,
disable query and payload capture unless those attributes are required.
| batch | true | batch span processor (see flushing below) |
| max_queue_size | null | maximum queued spans; null uses OTEL_BSP_MAX_QUEUE_SIZE/SDK default; spans are dropped when full |
| max_export_batch_size | null | spans per export batch; null uses OTEL_BSP_MAX_EXPORT_BATCH_SIZE/SDK default |
| scheduled_delay_ms | null | scheduled flush delay; null uses OTEL_BSP_SCHEDULE_DELAY/SDK default |
| export_timeout_ms | null | batch export timeout; null uses OTEL_BSP_EXPORT_TIMEOUT/SDK default |
| auto_flush | null | flush on span end when true (SDK default); set false to defer I/O to SpanFlusher or shutdown |
| register_shutdown_flush | true | registers a shutdown flush that force-flushes buffered spans (TracerProvider::forceFlush() — the provider is not shut down). OtelMiddleware re-registers the hook on every request — persistent worker runtimes (Rapira, FrankenPHP) clear shutdown functions with the request that registered them, and a one-time hook would strand later spans in the batch queue (#29). The correct default on every runtime; disable if you flush via SpanFlusher on a timer (RoadRunner/Swoole) |
| finish_request_before_flush | true | the shutdown hook calls fastcgi_finish_request() before the flush, so the client never waits for the OTLP round-trip (~100 ms measured without it — Yii3's SAPI emitter does not finish the request itself). Disable only if other shutdown functions still write to the response |
Span names & http.route
Span naming follows the OTel HTTP semconv: {method} {route} with a route
template (GET /users/{id}), plain {method} otherwise — never the raw path
(one span name per user id would wreck operation search in Tempo/Jaeger; the
raw path is always in the url.path attribute). Wire the router-aware resolver
app-side (it needs yiisoft/router, which is optional):
// config/common/di.php use Rasuvaeff\Yii3TelemetryOtel\CurrentRouteNameResolver; use Rasuvaeff\Yii3TelemetryOtel\RouteNameResolverInterface; return [ RouteNameResolverInterface::class => CurrentRouteNameResolver::class, ];
CurrentRouteNameResolver reads the matched yiisoft/router pattern after the
handler ran, so the tracing middleware can stay first in the stack. Unmatched
requests (404s, scanners) keep the bare {method} name. A custom resolver is a
one-method interface: resolve(ServerRequestInterface): ?string.
Sampling
The provider uses the SDK sampler configuration — the standard OTel env vars:
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1 # keep 10% of new traces
Unset, it defaults to parentbased_always_on (trace everything, honour the
incoming decision). To hardcode a sampler instead, pass it to the factory:
new OtelTracerProviderFactory(serviceName: '...', sampler: new AlwaysOffSampler()).
A dropped trace still runs your callback — $span is simply non-recording (the
frozen core contract).
Standalone Yii DI example
examples/05_yii_di.php loads the shipped config/di.php and config/di-web.php
definitions, resolves the SDK provider and core Tracer binding, and runs the
middleware with an in-memory exporter. It needs no collector:
composer require rasuvaeff/yii3-telemetry-otel nyholm/psr7 php examples/05_yii_di.php
In a real Yii application, merge these package config files through the normal
Yii config plugin, keep OtelMiddleware near the start of the PSR-15 stack, and
set OTEL_SERVICE_NAME plus OTEL_EXPORTER_OTLP_ENDPOINT for production OTLP.
Build a provider manually
use Rasuvaeff\Yii3TelemetryOtel\OtelTracerProvider; use Rasuvaeff\Yii3TelemetryOtel\OtelTracerProviderFactory; use Rasuvaeff\Yii3TelemetryOtel\OtlpExporterFactory; $exporter = (new OtlpExporterFactory())->create('http://collector:4318'); $sdkProvider = (new OtelTracerProviderFactory(serviceName: 'checkout-api'))->create($exporter); $tracer = (new OtelTracerProvider($sdkProvider))->getTracer(); $tracer->trace('checkout.process', static function ($span): void { $span->setAttribute('order.id', 'ORD-1'); });
The $tracer is a core TracerInterface — the frozen trace() contract applies
(returns the callback value; on exception records it, sets status Error, ends,
re-throws; nested spans inherit the parent trace id).
Classes
| Class | Purpose |
|---|---|
OtelTracerProvider |
core TracerProviderInterface over the OTel SDK |
OtelTracer / OtelSpan |
adapters: core facade → OTel span |
OtelTracerProviderFactory |
builds an SDK TracerProvider from an exporter (batch by default) |
OtlpExporterFactory |
builds the OTLP/HTTP span exporter |
RouteNameResolverInterface / CurrentRouteNameResolver |
route-template span names ({method} {route}) — see above |
ConsoleCommandSpanListener |
root span per console command (cron) — see Console commands |
OtelMiddleware |
PSR-15 SERVER root span + incoming-context extraction |
TraceContextExtractor / TraceContextInjector |
W3C context in / out |
SpanFlusher |
forceFlush() for long-running workers |
ShutdownFlushHook |
the DI-registered shutdown hook: force-flushes, never shuts the provider down |
Ending spans vs flushing the exporter
Middleware ends the root span in finally every request (no span leak). Flushing
the batch exporter is separate — new TracerProvider(...) registers no
automatic shutdown flush, so with register_shutdown_flush: true (the default)
the DI wiring registers one for you (ShutdownFlushHook); the hook calls forceFlush() — it exports what is buffered and keeps the
provider usable, it never calls shutdown(). That makes the hook safe on every
runtime, including the ones where shutdown functions run after every
request:
A shutdown function registered once, at provider build, is cleared with the
request that registered it on persistent worker runtimes (Rapira's HTTP
worker, FrankenPHP worker mode) — a one-time hook would flush after the first
request only, leaving every later span in the batch queue until it fills
(#29). So the web half of the guarantee lives where a request is known to
start: OtelMiddleware re-registers the flush on every request; the
boot-time registration covers the console commands and apps that run no
middleware (on CLI, shutdown functions run at process exit — exactly right).
| Runtime | With the default register_shutdown_flush: true |
|---|---|
| php-fpm | The hook runs at the end of every request, after the response was sent with fastcgi_finish_request — one OTLP round-trip per request. That is the only correct option on fpm: there is no user-land worker-shutdown hook, and batch-buffered spans are otherwise lost when fpm recycles the worker. (batch: false / SimpleSpanProcessor is the alternative: export per span) |
| Rapira HTTP worker / FrankenPHP worker mode | Shutdown functions run after every request here — and are cleared with the request that registered them. OtelMiddleware re-registers the force-flush per request, so every request's spans are exported at its end (#29; before #26 a shutdown() hook closed the processor after request 1 and silently dropped every later span). Apps that run no middleware keep the boot-time hook only — prefer batch: false there |
| RoadRunner / Swoole | The force-flush hook fires per request / worker iteration. If you flush via SpanFlusher::flush() on a timer instead, set register_shutdown_flush: false, so a crash loses at most one interval |
| CLI / cron | The hook fires once at process exit — exactly right |
use Rasuvaeff\Yii3TelemetryOtel\SpanFlusher; $flusher = new SpanFlusher($sdkProvider); // php-fpm / CLI: register_shutdown_function(static fn (): bool => $flusher->flush()); // RoadRunner: call $flusher->flush() on worker stop instead.
Collector down / latency
Export is synchronous in PHP: the OTLP request blocks the process that flushes. The package is fail-open on errors, but not on latency. With the SDK defaults (10 s timeout, 3 retries with back-off) a hanging collector can pin one worker for tens of seconds per flush.
The batch processor queue is bounded. When max_queue_size is reached, newly
ended sampled spans are dropped until queued spans are exported. With
auto_flush: true, the SDK may export synchronously from span->end() when a
batch threshold or schedule is reached; set auto_flush: false when span
completion must not perform export I/O, and call SpanFlusher::flush() from a
worker timer or shutdown hook. Monitor the SDK processor queue and dropped-span
counters when tuning these limits.
On php-fpm the shutdown flush runs after fastcgi_finish_request(), so the
client no longer waits — but the FPM worker stays busy until the export ends,
and under load a slow collector exhausts the pool.
Recommended production setup:
- run a local collector agent (sidecar / host daemon) so the export is a loopback call, and let the agent forward to the backend;
- for web set a short timeout and few retries:
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT=1500 # milliseconds
// config/params.php 'rasuvaeff/yii3-telemetry-otel' => [ 'max_retries' => 0, // 0-1 for web; keep the default for cron/workers ],
Async context boundaries
The OTel context storage used by this package is fiber-aware on supported PHP
builds. Start and finish each outgoing client span in the same Fiber; the
TraceContextInjector then injects that client's span id even when multiple
requests are suspended and resumed in an interleaved order. trace() and
middleware scopes detach in finally, including exception paths, so settling a
promise must not leave a span active. This guarantee covers PHP fibers and the
SDK's context storage only. Swoole, event-loop, and other coroutine runtimes
need their own context integration and must not assume generic safety.
Manual wiring: (new OtlpExporterFactory())->create($endpoint, $contentType, timeout: 1.5, maxRetries: 0, retryDelayMs: 100).
Console commands
OtelMiddleware is web-only. For console commands (cron!) register
ConsoleCommandSpanListener — it brackets ApplicationStartup/ApplicationShutdown
in an ACTIVATED root span console <command>, so DB/HTTP instrumentation spans
become its children instead of flooding the backend with root-less db.query
traces. A non-zero exit code marks the span as an error.
// config/console/events.php (needs yiisoft/yii-console — optional dep) use Rasuvaeff\Yii3TelemetryOtel\ConsoleCommandSpanListener; use Yiisoft\Yii\Console\Event\ApplicationShutdown; use Yiisoft\Yii\Console\Event\ApplicationStartup; return [ ApplicationStartup::class => [[ConsoleCommandSpanListener::class, 'onStartup']], ApplicationShutdown::class => [[ConsoleCommandSpanListener::class, 'onShutdown']], ];
Long-running commands
queue:listen and other daemon loops would keep one root span open for hours or
days: every processed job becomes its child (unrelated messages share a trace),
the span is exported only when the process stops (never on SIGKILL), and its
duration is meaningless. So the listener opens no span for commands listed in
excluded_commands (default ['queue:listen', 'queue:listen-all']). Entries
match the command name exactly, or as a prefix when they end with *:
'rasuvaeff/yii3-telemetry-otel' => [ 'excluded_commands' => ['queue:listen', 'queue:listen-all', 'outbox:*'], ],
The listener is bound in config/di.php with this param; keep resolving it from
the container. Per-message spans should come from the queue consume code — see
the queue tracing in rasuvaeff/yii3-telemetry
(rasuvaeff/yii3-telemetry#29).
For ad-hoc scripts without yii-console, trace() still works:
$tracer->trace('cron.sync-orders', fn (SpanInterface $span) => $command->run(), traceKind: TraceKind::Internal);
Security
- Distributed-trace headers are validated by the core propagator; malformed
traceparentis ignored, not trusted. - No credentials are placed in URLs; the OTLP endpoint is configuration.
- The middleware records
http.request.method,url.path,server.address,http.response.status_code— avoid adding high-cardinality or sensitive attributes.
Examples
Runnable scripts in examples/: in-memory export, the middleware,
and OTLP provider setup. See examples/README.md. A
full-stack docker-compose (collector + Tempo + Grafana) lives in
examples/docker-compose/.
OTLP smoke check
Start that stack, emit a uniquely named span, then verify that Tempo indexed it:
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 php examples/04_otlp_smoke.php
curl -fsSG http://localhost:3200/api/search \
--data-urlencode 'q={ name = "yii3-telemetry-otel.smoke" }'
The Tempo response must contain a trace (or find the same span in Grafana at
http://localhost:3000). An exporter HTTP 2xx alone is insufficient: a generic
HTTP dump endpoint can accept the payload without decoding it as OTLP.
Dependency analysers
This leaf package is selected by the root application through config-plugin and may legitimately have no class reference in an autoloaded source directory. Keep the direct dependency: the application, not a core package, selects the backend or bridge. Scope the Composer Dependency Analyser exception to this package:
use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; return (new Configuration())->ignoreErrorsOnPackage( 'rasuvaeff/yii3-telemetry-otel', [ErrorType::UNUSED_DEPENDENCY], );
composer-require-checker detects used but undeclared symbols, not unused
packages, so this config-only dependency needs no require-checker suppression.
Development
The core is resolved via a path repository during local development, so run
Docker with the monorepo root mounted as /repo:
docker run --rm -v /path/to/monorepo:/repo -w /repo/yii3-telemetry-otel \ composer:2 composer build
See AGENTS.md for the full command set and the publish checklist.
License
BSD-3-Clause. See LICENSE.md.