Search by

rasuvaeff / yii3-telemetry-otel

rasuvaeff

OpenTelemetry tracing backend for rasuvaeff/yii3-telemetry (Yii3)

Package info

github.com/rasuvaeff/yii3-telemetry-otel

pkg:composer/rasuvaeff/yii3-telemetry-otel

Statistics

Installs: 999

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 0

v1.2.2 2026-10-17 19:44 UTC

This package is auto-updated.

Last update: 2026-10-19 14:15:00 UTC


README

Stable Version Total Downloads Build Static Analysis Psalm Level PHP License Русская версия

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.0
  • open-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 traceparent is 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.