From f13aa3a8ce3274d6be611e539f1536d477a695f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Reyrol?= Date: Tue, 19 May 2026 12:58:10 +0200 Subject: [PATCH] feat(Runtime): add FrankenPHP worker-mode support and Globals provider source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP's shared-nothing model resets the MeterProvider on every request, so cumulative counters collapse at the collector. Under FrankenPHP worker mode the kernel terminates per request but the worker stays alive, which the bundle's kernel.terminate subscriber broke by calling provider->shutdown(). Detect FrankenPHP via Symfony 7.4's $_SERVER[APP_RUNTIME_MODE]=worker=1 (with explicit `runtime` override), switch the subscriber to forceFlush() under worker mode and defer shutdown to register_shutdown_function, and inject process.pid into the resource so N workers produce N distinct series. Also add `provider_source: globals` for setups where the SDK is bootstrapped externally (OTEL_PHP_AUTOLOAD_ENABLED=true) — the bundle's provider services delegate to OpenTelemetry\API\Globals instead of building their own. In the default `di` mode the bundle publishes its DI-built providers back into Globals on first kernel.request so auto-instrumentation contrib packages see the same instances. --- docs/src/.vuepress/config.js | 1 + docs/src/how-to/frankenphp-runtime.md | 80 ++++++++++++++ src/DependencyInjection/Configuration.php | 12 +++ .../OpenTelemetryExtension.php | 44 ++++++++ .../OpenTelemetryLogsExtension.php | 3 +- .../OpenTelemetryTracesExtension.php | 3 +- .../ObservableHttpKernelEventSubscriber.php | 31 +++++- .../Globals/GlobalsInitializer.php | 102 ++++++++++++++++++ .../GlobalsLoggerProviderFactory.php | 25 +++++ .../Log/LoggerProvider/LoggerProviderEnum.php | 1 + .../GlobalsMeterProviderFactory.php | 26 +++++ .../MeterProvider/MeterProviderEnum.php | 1 + src/OpenTelemetry/ProviderSource.php | 9 ++ .../Resource/ResourceInfoFactory.php | 7 ++ .../GlobalsTracerProviderFactory.php | 27 +++++ .../TracerProvider/TraceProviderEnum.php | 1 + src/Resources/config/services.php | 13 +++ src/Resources/config/services_logs.php | 4 + .../services_metering_instrumentation.php | 6 +- src/Resources/config/services_metrics.php | 4 + src/Resources/config/services_traces.php | 4 + src/Runtime/RuntimeDetector.php | 62 +++++++++++ src/Runtime/RuntimeMode.php | 18 ++++ .../config/packages/open_telemetry.yaml | 12 +++ .../Application/config/routes/routes.php | 6 ++ .../src/Controller/IncrementController.php | 23 ++++ .../Runtime/ShutdownSemanticsTest.php | 31 ++++++ .../Runtime/WorkerModeAccumulationTest.php | 56 ++++++++++ .../Functional/Runtime/WorkerResourceTest.php | 25 +++++ .../Functional/Runtime/WorkerShutdownTest.php | 31 ++++++ .../ConfigurationFormatTest.php | 2 + .../DependencyInjection/ConfigurationTest.php | 14 ++- 32 files changed, 677 insertions(+), 7 deletions(-) create mode 100644 docs/src/how-to/frankenphp-runtime.md create mode 100644 src/OpenTelemetry/Globals/GlobalsInitializer.php create mode 100644 src/OpenTelemetry/Log/LoggerProvider/GlobalsLoggerProviderFactory.php create mode 100644 src/OpenTelemetry/Metric/MeterProvider/GlobalsMeterProviderFactory.php create mode 100644 src/OpenTelemetry/ProviderSource.php create mode 100644 src/OpenTelemetry/Trace/TracerProvider/GlobalsTracerProviderFactory.php create mode 100644 src/Runtime/RuntimeDetector.php create mode 100644 src/Runtime/RuntimeMode.php create mode 100644 tests/Functional/Application/src/Controller/IncrementController.php create mode 100644 tests/Functional/Runtime/ShutdownSemanticsTest.php create mode 100644 tests/Functional/Runtime/WorkerModeAccumulationTest.php create mode 100644 tests/Functional/Runtime/WorkerResourceTest.php create mode 100644 tests/Functional/Runtime/WorkerShutdownTest.php diff --git a/docs/src/.vuepress/config.js b/docs/src/.vuepress/config.js index 9d97d015..c944245c 100644 --- a/docs/src/.vuepress/config.js +++ b/docs/src/.vuepress/config.js @@ -43,6 +43,7 @@ export default defineUserConfig({ text: 'How To', children: [ '/how-to/docker-env-setup.md', + '/how-to/frankenphp-runtime.md', ], }, ], diff --git a/docs/src/how-to/frankenphp-runtime.md b/docs/src/how-to/frankenphp-runtime.md new file mode 100644 index 00000000..4d024ea3 --- /dev/null +++ b/docs/src/how-to/frankenphp-runtime.md @@ -0,0 +1,80 @@ +# FrankenPHP and other long-running runtimes + +PHP's shared-nothing request model is a poor fit for OpenTelemetry metrics. Each FPM process gets its own `MeterProvider`, so cumulative counters reset every request and the collector sees the latest value instead of an accumulating series. Spans and logs are less affected because they're already per-request; metrics are the painful case. + +[FrankenPHP](https://frankenphp.dev/)'s worker mode keeps the PHP process resident between requests. Combined with the bundle settings below it gives you the long-lived state the OpenTelemetry SDK was designed for. + +## Quick start + +```yaml +# config/packages/open_telemetry.yaml +open_telemetry: + runtime: auto # auto-detect FrankenPHP worker mode (default) + provider_source: di # build providers via this bundle (default) + # ... your existing service / traces / metrics / logs config +``` + +Then run your application through FrankenPHP worker mode. On Symfony 7.4+ this is built into `symfony/runtime`; on older versions install `runtime/frankenphp-symfony` and set `APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime`. + +That's the whole opt-in: when `runtime` is `auto` the bundle inspects `$_SERVER['APP_RUNTIME_MODE']` (set by Symfony's `FrankenPhpWorkerRunner` to `web=1&worker=1`) and switches the `kernel.terminate` subscriber from `shutdown()` to `forceFlush()`. Counters built once during worker boot keep accumulating across all requests in that worker. + +## The `runtime` config key + +```yaml +open_telemetry: + runtime: auto | classic | frankenphp_worker +``` + +- `auto` (default) — detect at runtime. Returns `frankenphp_worker` when `$_SERVER['APP_RUNTIME_MODE']` contains `worker=1`, or when `frankenphp_handle_request()` exists and `APP_RUNTIME` resolves to a FrankenPHP runtime class. +- `classic` — force shared-nothing semantics. The kernel.terminate subscriber calls `MeterProvider::shutdown()` after every request. Use this when running under FPM, the built-in PHP server, or for one-shot CLI commands. +- `frankenphp_worker` — force worker semantics. The subscriber calls `forceFlush()` instead and defers `shutdown()` to `register_shutdown_function` so the worker can keep accumulating across iterations. + +You only need an explicit value if auto-detection misses your setup or you want determinism in tests. + +## Multi-worker resource attributes + +A single FrankenPHP server runs N worker processes (often `2 × CPU`). Each worker has its own MeterProvider and its own in-memory counter. If they all reported under the same service identity, the collector would see N writers for a single time series and behaviour becomes undefined. + +The bundle automatically adds `process.pid` to the resource composition (semconv attribute) so each worker becomes a distinct series the backend can sum across. Nothing to configure — it works in classic mode too, but the impact is only meaningful under worker mode. + +FrankenPHP does not expose a stable per-worker identifier beyond the OS PID; the Caddyfile `worker { name … }` directive names the worker pool for FrankenPHP's own metrics/logs but is not surfaced to PHP. PID alone is sufficient. + +## `provider_source: globals` — externally bootstrapped SDK + +If you bootstrap the OpenTelemetry SDK outside the bundle (`OTEL_PHP_AUTOLOAD_ENABLED=true` runs SDK initializers during Composer autoload, or you wire `Sdk::builder()->buildAndRegisterGlobal()` manually in your FrankenPHP worker entry script) you don't want the bundle to build its own provider pipeline — you want it to consume the providers you already published into `OpenTelemetry\API\Globals`. + +```yaml +open_telemetry: + provider_source: globals + # service / instrumentation config still required; + # traces.processors / traces.exporters / metrics.exporters / logs.* sections still parsed + # but their values are ignored because providers come from Globals. +``` + +In this mode: + +- Every provider service the bundle builds is a thin delegate over `Globals::tracerProvider()`, `meterProvider()`, `loggerProvider()`. Construction-time arguments (samplers, processors, exporters) are accepted but ignored. +- The bundle still owns **instrumentation** — event subscribers, decorators, middleware. These consume the Globals-sourced providers transparently. +- If `Globals::*Provider()` returns an API-level no-op (because no external bootstrap published an SDK provider before the bundle resolved its services), the bundle's `GlobalsXProviderFactory` throws a `LogicException` with a pointer to fix the bootstrap order. + +When `provider_source` is `di` (the default) the bundle additionally publishes its DI-built providers *into* Globals on first `kernel.request`, so third-party libraries reaching for `Globals::*Provider()` see the same instances the bundle uses. No flag to set — this is automatic. + +### Choosing between `di` and `globals` + +| You want… | Use | +|---|---| +| The bundle to own provider construction; everything configured via YAML | `di` (default) | +| Auto-loaded SDK contrib instrumentation (the `open-telemetry/opentelemetry-auto-*` packages) to share providers with the bundle | `di` — they reach via Globals, the bundle publishes there automatically | +| The SDK bootstrapped externally (e.g. for compatibility with a deployment-level config) and the bundle to consume those providers | `globals` | + +## Known limitations + +- **No periodic export.** PHP has no native background threads, so a `PeriodicExportingMetricReader` cannot truly tick on a timer. The bundle uses an `ExportingReader` that flushes on `kernel.terminate` — under steady traffic that's an export per request, which is fine. Under idle conditions exports lag until the next request. +- **State leaks.** Worker mode reuses services across requests. The OpenTelemetry providers are designed to do this safely, but application services with mutable state need `Symfony\Contracts\Service\ResetInterface` or they will leak. The FrankenPHP docs recommend [igor-php/igor-php](https://github.com/igor-php/igor-php) as a static linter to surface these. +- **Provider source rules per signal.** When `provider_source: globals` is set globally, *all* configured providers in `traces.providers`, `metrics.providers`, and `logs.providers` are forced to `type: globals`. To mix-and-match (e.g. metrics from Globals but traces from DI), set `provider_source: di` and explicitly set `type: globals` on the providers that should consume Globals. + +## Verifying it works + +A functional test under `tests/Functional/Runtime/WorkerModeAccumulationTest` boots the test kernel with `runtime: frankenphp_worker`, disables `KernelBrowser` reboot (so the kernel reuses its container across `$client->request()` calls like a real FrankenPHP worker), issues two `/increment/{value}` requests, and asserts both values reach the exporter via the same provider. That's the regression test for the original bug — the `MeterProvider` no longer dies on `kernel.terminate` in worker mode. + +For end-to-end validation against a real FrankenPHP worker, see `tests/Acceptance/FrankenPHPRuntimeTest` (run via the dedicated CI job). diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2dc970cf..a844d47c 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -11,9 +11,11 @@ use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\ExemplarFilterEnum; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\MeterProvider\MeterProviderEnum; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\MetricExporter\MetricTemporalityEnum; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\ProviderSource; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Trace\SpanProcessor\SpanProcessorEnum; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Trace\TracerProvider\TraceProviderEnum; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Trace\TraceSamplerEnum; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\Runtime\RuntimeMode; use Monolog\Level; use OpenTelemetry\SDK\Logs\Processor\BatchLogRecordProcessor; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; @@ -35,6 +37,16 @@ public function getConfigTreeBuilder(): TreeBuilder ->info('Service ID used for telemetry export transports. Must implement PSR-18 ClientInterface and PSR-17 RequestFactoryInterface, StreamFactoryInterface. Defaults to Symfony Psr18Client.') ->defaultNull() ->end() + ->enumNode('runtime') + ->info('PHP runtime model. `auto` detects FrankenPHP worker mode via $_SERVER[APP_RUNTIME_MODE]; override with `classic` (FPM / CLI) or `frankenphp_worker` (long-lived worker).') + ->defaultValue(RuntimeMode::Auto->value) + ->values(array_map(static fn (RuntimeMode $mode) => $mode->value, RuntimeMode::cases())) + ->end() + ->enumNode('provider_source') + ->info('Where OpenTelemetry providers come from. `di` builds them via this bundle (default). `globals` consumes providers from OpenTelemetry\\API\\Globals (the SDK must be bootstrapped externally, e.g. OTEL_PHP_AUTOLOAD_ENABLED=true).') + ->defaultValue(ProviderSource::Di->value) + ->values(array_map(static fn (ProviderSource $source) => $source->value, ProviderSource::cases())) + ->end() ->end() ; diff --git a/src/DependencyInjection/OpenTelemetryExtension.php b/src/DependencyInjection/OpenTelemetryExtension.php index 399f52ba..59b2afff 100644 --- a/src/DependencyInjection/OpenTelemetryExtension.php +++ b/src/DependencyInjection/OpenTelemetryExtension.php @@ -4,6 +4,9 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use FriendsOfOpenTelemetry\OpenTelemetryBundle\Instrumentation\InstrumentationTypeEnum; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\ProviderSource; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\Runtime\RuntimeDetector; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\Runtime\RuntimeMode; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\Cache\CacheItem; use Symfony\Component\Config\FileLocator; @@ -53,15 +56,56 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container $loader->load('services_tracing_instrumentation.php'); $loader->load('services_metering_instrumentation.php'); + $this->registerRuntime($mergedConfig, $container); $this->registerTransportHttpClient($mergedConfig['transport_http_client'], $container); $this->registerService($mergedConfig['service'], $container); $this->registerInstrumentation($mergedConfig['instrumentation'], $container); + if (ProviderSource::Globals->value === $mergedConfig['provider_source']) { + $mergedConfig['traces'] = $this->forceProviderType($mergedConfig['traces']); + $mergedConfig['metrics'] = $this->forceProviderType($mergedConfig['metrics']); + $mergedConfig['logs'] = $this->forceProviderType($mergedConfig['logs']); + } + (new OpenTelemetryTracesExtension())($mergedConfig['traces'], $container); (new OpenTelemetryMetricsExtension())($mergedConfig['metrics'], $container); (new OpenTelemetryLogsExtension())($mergedConfig['logs'], $container); } + /** + * @param array{providers: array} $section + * + * @return array{providers: array>} + */ + private function forceProviderType(array $section): array + { + foreach (array_keys($section['providers']) as $name) { + $section['providers'][$name]['type'] = 'globals'; + } + + return $section; + } + + /** + * @param array{ + * runtime: string, + * provider_source: string, + * } $config + */ + private function registerRuntime(array $config, ContainerBuilder $container): void + { + $configuredRuntime = RuntimeMode::from($config['runtime']); + $providerSource = ProviderSource::from($config['provider_source']); + + $container->setParameter('open_telemetry.runtime.configured', $configuredRuntime->value); + $container->setParameter('open_telemetry.provider_source', $providerSource->value); + + $container->register('open_telemetry.runtime_detector', RuntimeDetector::class) + ->setArguments([$configuredRuntime]) + ->setPublic(false); + $container->setAlias(RuntimeDetector::class, 'open_telemetry.runtime_detector'); + } + /** * @param array{ * namespace: string, diff --git a/src/DependencyInjection/OpenTelemetryLogsExtension.php b/src/DependencyInjection/OpenTelemetryLogsExtension.php index c8b1db75..a4cdbac9 100644 --- a/src/DependencyInjection/OpenTelemetryLogsExtension.php +++ b/src/DependencyInjection/OpenTelemetryLogsExtension.php @@ -142,7 +142,8 @@ private function loadLogProvider(string $name, array $config): void ->setArguments([ isset($config['processor']) ? new Reference($config['processor']) : null, new Reference('open_telemetry.resource_info'), - ]); + ]) + ->addTag('open_telemetry.logs.provider'); } /** diff --git a/src/DependencyInjection/OpenTelemetryTracesExtension.php b/src/DependencyInjection/OpenTelemetryTracesExtension.php index 4e4d8795..7566a0bb 100644 --- a/src/DependencyInjection/OpenTelemetryTracesExtension.php +++ b/src/DependencyInjection/OpenTelemetryTracesExtension.php @@ -134,7 +134,8 @@ private function loadTraceProvider(string $name, array $config): void $sampler, isset($config['processors']) ? array_map(fn (string $processor) => new Reference($processor), $config['processors']) : null, new Reference('open_telemetry.resource_info'), - ]); + ]) + ->addTag('open_telemetry.traces.provider'); } /** diff --git a/src/Instrumentation/Symfony/HttpKernel/ObservableHttpKernelEventSubscriber.php b/src/Instrumentation/Symfony/HttpKernel/ObservableHttpKernelEventSubscriber.php index e8dfc7e7..216d8b34 100644 --- a/src/Instrumentation/Symfony/HttpKernel/ObservableHttpKernelEventSubscriber.php +++ b/src/Instrumentation/Symfony/HttpKernel/ObservableHttpKernelEventSubscriber.php @@ -2,6 +2,7 @@ namespace FriendsOfOpenTelemetry\OpenTelemetryBundle\Instrumentation\Symfony\HttpKernel; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\Runtime\RuntimeDetector; use OpenTelemetry\SDK\Metrics\MeterProviderInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\TerminateEvent; @@ -9,11 +10,14 @@ final class ObservableHttpKernelEventSubscriber implements EventSubscriberInterface { + private bool $shutdownRegistered = false; + public function __construct( /** - * @var list + * @var iterable */ private readonly iterable $locator, + private readonly RuntimeDetector $runtimeDetector, ) { } @@ -28,8 +32,33 @@ public static function getSubscribedEvents(): array public function flush(TerminateEvent $event): void { + if ($this->runtimeDetector->isLongRunning()) { + $this->registerWorkerShutdown(); + foreach ($this->locator as $provider) { + $provider->forceFlush(); + } + + return; + } + foreach ($this->locator as $provider) { $provider->shutdown(); } } + + private function registerWorkerShutdown(): void + { + if ($this->shutdownRegistered) { + return; + } + + $providers = $this->locator; + register_shutdown_function(static function () use ($providers): void { + foreach ($providers as $provider) { + $provider->shutdown(); + } + }); + + $this->shutdownRegistered = true; + } } diff --git a/src/OpenTelemetry/Globals/GlobalsInitializer.php b/src/OpenTelemetry/Globals/GlobalsInitializer.php new file mode 100644 index 00000000..a33e595f --- /dev/null +++ b/src/OpenTelemetry/Globals/GlobalsInitializer.php @@ -0,0 +1,102 @@ + $tracerProviders + * @param iterable $meterProviders + * @param iterable $loggerProviders + */ + public function __construct( + private readonly iterable $tracerProviders, + private readonly iterable $meterProviders, + private readonly iterable $loggerProviders, + private readonly string $providerSource, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => [['register', 99999]], + ]; + } + + public function register(): void + { + if ($this->registered) { + return; + } + $this->registered = true; + + if (ProviderSource::Di->value !== $this->providerSource) { + return; + } + + $tracerProvider = self::first($this->tracerProviders); + $meterProvider = self::first($this->meterProviders); + $loggerProvider = self::first($this->loggerProviders); + + if (null === $tracerProvider && null === $meterProvider && null === $loggerProvider) { + return; + } + + Globals::registerInitializer(static function (Configurator $configurator) use ($tracerProvider, $meterProvider, $loggerProvider): Configurator { + if ($tracerProvider instanceof TracerProviderInterface) { + $configurator = $configurator->withTracerProvider($tracerProvider); + } + if ($meterProvider instanceof MeterProviderInterface) { + $configurator = $configurator->withMeterProvider($meterProvider); + } + if ($loggerProvider instanceof LoggerProviderInterface) { + $configurator = $configurator->withLoggerProvider($loggerProvider); + } + + return $configurator; + }); + } + + /** + * @template T of object + * + * @param iterable $iter + * + * @return T|null + */ + private static function first(iterable $iter): ?object + { + foreach ($iter as $item) { + return $item; + } + + return null; + } +} diff --git a/src/OpenTelemetry/Log/LoggerProvider/GlobalsLoggerProviderFactory.php b/src/OpenTelemetry/Log/LoggerProvider/GlobalsLoggerProviderFactory.php new file mode 100644 index 00000000..bdacc172 --- /dev/null +++ b/src/OpenTelemetry/Log/LoggerProvider/GlobalsLoggerProviderFactory.php @@ -0,0 +1,25 @@ + $name, ServiceAttributes::SERVICE_VERSION => $version, DeploymentIncubatingAttributes::DEPLOYMENT_ENVIRONMENT_NAME => $environment, + ProcessIncubatingAttributes::PROCESS_PID => getmypid(), ]), Version::VERSION_1_38_0->url())); } } diff --git a/src/OpenTelemetry/Trace/TracerProvider/GlobalsTracerProviderFactory.php b/src/OpenTelemetry/Trace/TracerProvider/GlobalsTracerProviderFactory.php new file mode 100644 index 00000000..9cb166cd --- /dev/null +++ b/src/OpenTelemetry/Trace/TracerProvider/GlobalsTracerProviderFactory.php @@ -0,0 +1,27 @@ +buildAndRegisterGlobal() call. + */ +final readonly class GlobalsTracerProviderFactory extends AbstractTracerProviderFactory +{ + public function createProvider(?SamplerInterface $sampler = null, array $processors = [], ?ResourceInfo $info = null): TracerProviderInterface + { + $provider = Globals::tracerProvider(); + if (!$provider instanceof TracerProviderInterface) { + throw new \LogicException(sprintf('OpenTelemetry\\API\\Globals returned a TracerProvider of type %s, which does not implement the SDK TracerProviderInterface. Ensure the OpenTelemetry SDK is bootstrapped (e.g. OTEL_PHP_AUTOLOAD_ENABLED=true) before this bundle resolves provider services.', $provider::class)); + } + + return $provider; + } +} diff --git a/src/OpenTelemetry/Trace/TracerProvider/TraceProviderEnum.php b/src/OpenTelemetry/Trace/TracerProvider/TraceProviderEnum.php index 101cd04d..8467305c 100644 --- a/src/OpenTelemetry/Trace/TracerProvider/TraceProviderEnum.php +++ b/src/OpenTelemetry/Trace/TracerProvider/TraceProviderEnum.php @@ -6,5 +6,6 @@ enum TraceProviderEnum: string { case Default = 'default'; case Noop = 'noop'; + case Globals = 'globals'; // case Traceable = 'traceable'; } diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index c4a7f21c..b33312ab 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -4,6 +4,7 @@ use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Context\Propagator\PropagatorFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Exporter\ExporterDsn; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Exporter\OtlpExporterOptions; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Globals\GlobalsInitializer; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Resource\ResourceInfoFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetryBundle; use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter; @@ -15,6 +16,9 @@ use OpenTelemetry\SDK\Resource\ResourceInfo; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use function Symfony\Component\DependencyInjection\Loader\Configurator\param; +use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; + return static function (ContainerConfigurator $container): void { $container->parameters() ->set('open_telemetry.bundle.name', OpenTelemetryBundle::name()) @@ -45,5 +49,14 @@ ->set('open_telemetry.otlp_exporter_options', OtlpExporterOptions::class) ->factory([OtlpExporterOptions::class, 'fromConfiguration']) + + ->set('open_telemetry.globals_initializer', GlobalsInitializer::class) + ->args([ + tagged_iterator('open_telemetry.traces.provider'), + tagged_iterator('open_telemetry.metrics.provider'), + tagged_iterator('open_telemetry.logs.provider'), + param('open_telemetry.provider_source'), + ]) + ->tag('kernel.event_subscriber') ; }; diff --git a/src/Resources/config/services_logs.php b/src/Resources/config/services_logs.php index c117f92c..a0a361f5 100644 --- a/src/Resources/config/services_logs.php +++ b/src/Resources/config/services_logs.php @@ -8,6 +8,7 @@ use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Log\LogExporter\OtlpLogExporterFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Log\LoggerProvider\AbstractLoggerProviderFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Log\LoggerProvider\DefaultLoggerProviderFactory; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Log\LoggerProvider\GlobalsLoggerProviderFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Log\LoggerProvider\NoopLoggerProviderFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Log\LogProcessor\AbstractLogProcessorFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Log\LogProcessor\BatchLogProcessorFactory; @@ -102,6 +103,9 @@ ->set('open_telemetry.logs.provider_factory.default', DefaultLoggerProviderFactory::class) ->parent('open_telemetry.logs.provider_factory.abstract') + ->set('open_telemetry.logs.provider_factory.globals', GlobalsLoggerProviderFactory::class) + ->parent('open_telemetry.logs.provider_factory.abstract') + ->set('open_telemetry.logs.provider_interface', LoggerProviderInterface::class) // Logger diff --git a/src/Resources/config/services_metering_instrumentation.php b/src/Resources/config/services_metering_instrumentation.php index ff379874..c9c48471 100644 --- a/src/Resources/config/services_metering_instrumentation.php +++ b/src/Resources/config/services_metering_instrumentation.php @@ -4,6 +4,7 @@ use FriendsOfOpenTelemetry\OpenTelemetryBundle\Instrumentation\Symfony\HttpKernel\ObservableHttpKernelEventSubscriber; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use function Symfony\Component\DependencyInjection\Loader\Configurator\service; use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; return static function (ContainerConfigurator $container): void { @@ -19,7 +20,10 @@ // HTTP Kernel ->set('open_telemetry.instrumentation.http_kernel.metric.event_subscriber', ObservableHttpKernelEventSubscriber::class) - ->args([tagged_iterator('open_telemetry.metrics.provider')]) + ->args([ + tagged_iterator('open_telemetry.metrics.provider'), + service('open_telemetry.runtime_detector'), + ]) ->tag('kernel.event_subscriber') ->tag('monolog.logger', ['channel' => 'open_telemetry']) diff --git a/src/Resources/config/services_metrics.php b/src/Resources/config/services_metrics.php index 1a46e515..a94fdedb 100644 --- a/src/Resources/config/services_metrics.php +++ b/src/Resources/config/services_metrics.php @@ -3,6 +3,7 @@ use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\ExemplarFilterFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\MeterProvider\AbstractMeterProviderFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\MeterProvider\DefaultMeterProviderFactory; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\MeterProvider\GlobalsMeterProviderFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\MeterProvider\NoopMeterProviderFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\MetricExporter\AbstractMetricExporterFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Metric\MetricExporter\ConsoleMetricExporterFactory; @@ -78,6 +79,9 @@ ->set('open_telemetry.metrics.provider_factory.default', DefaultMeterProviderFactory::class) ->parent('open_telemetry.metrics.provider_factory.abstract') + ->set('open_telemetry.metrics.provider_factory.globals', GlobalsMeterProviderFactory::class) + ->parent('open_telemetry.metrics.provider_factory.abstract') + ->set('open_telemetry.metrics.provider_interface', MeterProviderInterface::class) // Meter diff --git a/src/Resources/config/services_traces.php b/src/Resources/config/services_traces.php index 333d8dfc..2b56fb27 100644 --- a/src/Resources/config/services_traces.php +++ b/src/Resources/config/services_traces.php @@ -13,6 +13,7 @@ use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Trace\SpanProcessor\SimpleSpanProcessorFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Trace\TracerProvider\AbstractTracerProviderFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Trace\TracerProvider\DefaultTracerProviderFactory; +use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Trace\TracerProvider\GlobalsTracerProviderFactory; use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Trace\TracerProvider\NoopTracerProviderFactory; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\SDK\Trace\SpanExporterInterface; @@ -97,6 +98,9 @@ ->set('open_telemetry.traces.provider_factory.default', DefaultTracerProviderFactory::class) ->parent('open_telemetry.traces.provider_factory.abstract') + ->set('open_telemetry.traces.provider_factory.globals', GlobalsTracerProviderFactory::class) + ->parent('open_telemetry.traces.provider_factory.abstract') + ->set('open_telemetry.traces.provider_interface', TracerProviderInterface::class) // Tracer diff --git a/src/Runtime/RuntimeDetector.php b/src/Runtime/RuntimeDetector.php new file mode 100644 index 00000000..46468aaa --- /dev/null +++ b/src/Runtime/RuntimeDetector.php @@ -0,0 +1,62 @@ +configured); + } + + public function isLongRunning(): bool + { + return $this->getMode()->isLongRunning(); + } + + /** + * Symfony 7.4's FrankenPhpWorkerRunner sets $_SERVER['APP_RUNTIME_MODE'] = 'web=1&worker=1' on every + * request inside the worker loop. Detection therefore only returns FrankenPhpWorker once the first + * request is being handled — it is intentional that calls during container boot see Classic. + * + * @param array|null $server $_SERVER override, for testing + */ + public static function resolve(RuntimeMode $configured = RuntimeMode::Auto, ?array $server = null): RuntimeMode + { + if (RuntimeMode::Auto !== $configured) { + return $configured; + } + + $server ??= $_SERVER; + + if (self::isFrankenPhpWorker($server)) { + return RuntimeMode::FrankenPhpWorker; + } + + return RuntimeMode::Classic; + } + + /** + * @param array $server + */ + private static function isFrankenPhpWorker(array $server): bool + { + $runtimeMode = $server['APP_RUNTIME_MODE'] ?? null; + if (\is_string($runtimeMode) && str_contains($runtimeMode, 'worker=1')) { + return true; + } + + if (!\function_exists('frankenphp_handle_request')) { + return false; + } + + $runtime = $server['APP_RUNTIME'] ?? null; + + return \is_string($runtime) && str_contains($runtime, 'FrankenPhp'); + } +} diff --git a/src/Runtime/RuntimeMode.php b/src/Runtime/RuntimeMode.php new file mode 100644 index 00000000..5ab5f4cf --- /dev/null +++ b/src/Runtime/RuntimeMode.php @@ -0,0 +1,18 @@ + true, + self::Classic, self::Auto => false, + }; + } +} diff --git a/tests/Functional/Application/config/packages/open_telemetry.yaml b/tests/Functional/Application/config/packages/open_telemetry.yaml index 38bcd6b1..fb7cd870 100644 --- a/tests/Functional/Application/config/packages/open_telemetry.yaml +++ b/tests/Functional/Application/config/packages/open_telemetry.yaml @@ -129,3 +129,15 @@ when@empty_excludes: tracing: enabled: true exclude_paths: [] +when@worker_mode: + open_telemetry: + runtime: frankenphp_worker + instrumentation: + http_kernel: + type: auto + tracing: + enabled: true + exclude_paths: [] +when@globals_mode: + open_telemetry: + provider_source: globals diff --git a/tests/Functional/Application/config/routes/routes.php b/tests/Functional/Application/config/routes/routes.php index 24c7c42b..e3cc7858 100644 --- a/tests/Functional/Application/config/routes/routes.php +++ b/tests/Functional/Application/config/routes/routes.php @@ -1,5 +1,6 @@ add('php-config', '/php-config') ->controller([ActionTraceableController::class, 'phpConfig']) ->methods(['GET']); + + $routingConfigurator->add('increment', '/increment/{value}') + ->controller(IncrementController::class) + ->methods(['GET']) + ->requirements(['value' => '-?\\d+']); }; diff --git a/tests/Functional/Application/src/Controller/IncrementController.php b/tests/Functional/Application/src/Controller/IncrementController.php new file mode 100644 index 00000000..2312417f --- /dev/null +++ b/tests/Functional/Application/src/Controller/IncrementController.php @@ -0,0 +1,23 @@ + '-?\d+'])] + public function __invoke(int $value): JsonResponse + { + $this->meterService->count([$value]); + + return new JsonResponse(['value' => $value]); + } +} diff --git a/tests/Functional/Runtime/ShutdownSemanticsTest.php b/tests/Functional/Runtime/ShutdownSemanticsTest.php new file mode 100644 index 00000000..1196cffe --- /dev/null +++ b/tests/Functional/Runtime/ShutdownSemanticsTest.php @@ -0,0 +1,31 @@ +disableReboot(); + $client->request('GET', '/increment/1'); + self::assertResponseIsSuccessful(); + + $provider = self::getContainer()->get('open_telemetry.metrics.providers.default'); + self::assertInstanceOf(MeterProviderInterface::class, $provider); + + // shutdown() returns false when the provider is already closed. + self::assertFalse( + $provider->shutdown(), + 'In classic mode the kernel.terminate subscriber should have shut down the provider already', + ); + } +} diff --git a/tests/Functional/Runtime/WorkerModeAccumulationTest.php b/tests/Functional/Runtime/WorkerModeAccumulationTest.php new file mode 100644 index 00000000..0a5fd34a --- /dev/null +++ b/tests/Functional/Runtime/WorkerModeAccumulationTest.php @@ -0,0 +1,56 @@ +disableReboot(); + + $client->request('GET', '/increment/2'); + self::assertResponseIsSuccessful(); + + $client->request('GET', '/increment/6'); + self::assertResponseIsSuccessful(); + + $provider = self::getContainer()->get('open_telemetry.metrics.providers.default'); + self::assertInstanceOf(MeterProviderInterface::class, $provider); + self::assertTrue($provider->forceFlush(), 'Provider should still be live after worker-mode terminate'); + + $metrics = self::getMetrics(); + $dummyDataPoints = []; + foreach ($metrics as $metric) { + if ('dummy' !== $metric->name || !$metric->data instanceof Sum) { + continue; + } + foreach ($metric->data->dataPoints as $point) { + $dummyDataPoints[] = $point; + } + } + + self::assertNotEmpty($dummyDataPoints, 'Counter should have been exported at least once'); + + $total = array_sum(array_map(static fn (NumberDataPoint $p): int => $p->value, $dummyDataPoints)); + self::assertSame( + 8, + $total, + 'Counter increments from request 1 (2) and request 2 (6) must both reach the exporter, summing to 8', + ); + } +} diff --git a/tests/Functional/Runtime/WorkerResourceTest.php b/tests/Functional/Runtime/WorkerResourceTest.php new file mode 100644 index 00000000..91ea300b --- /dev/null +++ b/tests/Functional/Runtime/WorkerResourceTest.php @@ -0,0 +1,25 @@ +get('open_telemetry.resource_info'); + self::assertInstanceOf(ResourceInfo::class, $resource); + + $attrs = $resource->getAttributes()->toArray(); + self::assertArrayHasKey(ProcessIncubatingAttributes::PROCESS_PID, $attrs); + self::assertSame(getmypid(), $attrs[ProcessIncubatingAttributes::PROCESS_PID]); + } +} diff --git a/tests/Functional/Runtime/WorkerShutdownTest.php b/tests/Functional/Runtime/WorkerShutdownTest.php new file mode 100644 index 00000000..64b75166 --- /dev/null +++ b/tests/Functional/Runtime/WorkerShutdownTest.php @@ -0,0 +1,31 @@ +disableReboot(); + $client->request('GET', '/increment/1'); + self::assertResponseIsSuccessful(); + + $provider = self::getContainer()->get('open_telemetry.metrics.providers.default'); + self::assertInstanceOf(MeterProviderInterface::class, $provider); + + // In worker mode the subscriber must NOT have shut the provider down — forceFlush is the + // contract instead. shutdown() returning true here confirms the provider was still open. + self::assertTrue( + $provider->shutdown(), + 'In worker mode the kernel.terminate subscriber should leave the provider open', + ); + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationFormatTest.php b/tests/Unit/DependencyInjection/ConfigurationFormatTest.php index 45b139b4..0f6f20a0 100644 --- a/tests/Unit/DependencyInjection/ConfigurationFormatTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationFormatTest.php @@ -25,6 +25,8 @@ public function testDefaultCompatibility(): void { $expectedConfiguration = [ 'transport_http_client' => null, + 'runtime' => 'auto', + 'provider_source' => 'di', 'service' => [ 'namespace' => 'FriendsOfOpenTelemetry/OpenTelemetry', 'name' => 'Test', diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php index 1234a3cb..6f254e40 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -44,6 +44,8 @@ public function testEmptyConfiguration(): void self::assertSame([ 'transport_http_client' => null, + 'runtime' => 'auto', + 'provider_source' => 'di', 'service' => [], 'instrumentation' => [ 'cache' => [ @@ -151,6 +153,12 @@ public function testReferenceConfiguration(): void # Service ID used for telemetry export transports. Must implement PSR-18 ClientInterface and PSR-17 RequestFactoryInterface, StreamFactoryInterface. Defaults to Symfony Psr18Client. transport_http_client: null + + # PHP runtime model. `auto` detects FrankenPHP worker mode via \$_SERVER[APP_RUNTIME_MODE]; override with `classic` (FPM / CLI) or `frankenphp_worker` (long-lived worker). + runtime: auto # One of "auto"; "classic"; "frankenphp_worker" + + # Where OpenTelemetry providers come from. `di` builds them via this bundle (default). `globals` consumes providers from OpenTelemetry\\API\\Globals (the SDK must be bootstrapped externally, e.g. OTEL_PHP_AUTOLOAD_ENABLED=true). + provider_source: di # One of "di"; "globals" service: namespace: ~ # Required, Example: MyOrganization name: ~ # Required, Example: MyApp @@ -269,7 +277,7 @@ public function testReferenceConfiguration(): void # Prototype provider: - type: default # One of "default"; "noop", Required + type: default # One of "default"; "noop"; "globals", Required sampler: type: always_on # One of "always_off"; "always_on"; "parent_based_always_off"; "parent_based_always_on"; "parent_based_trace_id_ratio"; "trace_id_ratio"; "attribute_based"; "service", Required @@ -317,7 +325,7 @@ public function testReferenceConfiguration(): void # Prototype provider: - type: default # One of "noop"; "default", Required + type: default # One of "noop"; "default"; "globals", Required exporter: ~ filter: type: none # One of "all"; "none"; "with_sampled_trace"; "service" @@ -364,7 +372,7 @@ public function testReferenceConfiguration(): void # Prototype provider: - type: default # One of "default"; "noop", Required + type: default # One of "default"; "noop"; "globals", Required processor: ~ processors: