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: