Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/.vuepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default defineUserConfig({
text: 'How To',
children: [
'/how-to/docker-env-setup.md',
'/how-to/frankenphp-runtime.md',
],
},
],
Expand Down
80 changes: 80 additions & 0 deletions docs/src/how-to/frankenphp-runtime.md
Original file line number Diff line number Diff line change
@@ -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).
12 changes: 12 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
;

Expand Down
44 changes: 44 additions & 0 deletions src/DependencyInjection/OpenTelemetryExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{type: string}>} $section
*
* @return array{providers: array<string, array<string, mixed>>}
*/
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,
Expand Down
3 changes: 2 additions & 1 deletion src/DependencyInjection/OpenTelemetryLogsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/DependencyInjection/OpenTelemetryTracesExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@

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;
use Symfony\Component\HttpKernel\KernelEvents;

final class ObservableHttpKernelEventSubscriber implements EventSubscriberInterface
{
private bool $shutdownRegistered = false;

public function __construct(
/**
* @var list<MeterProviderInterface>
* @var iterable<MeterProviderInterface>
*/
private readonly iterable $locator,
private readonly RuntimeDetector $runtimeDetector,
) {
}

Expand All @@ -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;
}
}
102 changes: 102 additions & 0 deletions src/OpenTelemetry/Globals/GlobalsInitializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

namespace FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Globals;

use FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\ProviderSource;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Instrumentation\Configurator;
use OpenTelemetry\API\Logs\LoggerProviderInterface;
use OpenTelemetry\API\Metrics\MeterProviderInterface;
use OpenTelemetry\API\Trace\TracerProviderInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Publishes the bundle's DI-built providers into OpenTelemetry\API\Globals so third-party libraries
* and auto-instrumentation packages that reach for Globals::tracerProvider() / meterProvider() /
* loggerProvider() see the same instances the bundle uses.
*
* The registration is performed once, on the first kernel.request with the highest priority, so
* any code in the request path (or beyond) sees consistent providers. The Globals SDK invokes the
* initializer lazily on first read, so no provider is instantiated eagerly here.
*
* When provider_source is `globals` this initializer is a no-op — the SDK is bootstrapped externally
* and the bundle's services consume Globals rather than publish into them.
*
* Only the first tagged provider per signal is published. Bundles configuring multiple providers
* per signal can override the choice by adding `priority` to the tags.
*/
final class GlobalsInitializer implements EventSubscriberInterface
{
private bool $registered = false;

/**
* @param iterable<TracerProviderInterface> $tracerProviders
* @param iterable<MeterProviderInterface> $meterProviders
* @param iterable<LoggerProviderInterface> $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<T> $iter
*
* @return T|null
*/
private static function first(iterable $iter): ?object
{
foreach ($iter as $item) {
return $item;
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace FriendsOfOpenTelemetry\OpenTelemetryBundle\OpenTelemetry\Log\LoggerProvider;

use OpenTelemetry\API\Globals;
use OpenTelemetry\SDK\Logs\LoggerProviderInterface;
use OpenTelemetry\SDK\Logs\LogRecordProcessorInterface;
use OpenTelemetry\SDK\Resource\ResourceInfo;

/**
* Returns the LoggerProvider currently registered in OpenTelemetry\API\Globals. The arguments are
* accepted to honour the factory contract but ignored.
*/
final class GlobalsLoggerProviderFactory extends AbstractLoggerProviderFactory
{
public function createProvider(?LogRecordProcessorInterface $processor = null, ?ResourceInfo $resource = null): LoggerProviderInterface
{
$provider = Globals::loggerProvider();
if (!$provider instanceof LoggerProviderInterface) {
throw new \LogicException(sprintf('OpenTelemetry\\API\\Globals returned a LoggerProvider of type %s, which does not implement the SDK LoggerProviderInterface. Ensure the OpenTelemetry SDK is bootstrapped (e.g. OTEL_PHP_AUTOLOAD_ENABLED=true) before this bundle resolves provider services.', $provider::class));
}

return $provider;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ enum LoggerProviderEnum: string
{
case Default = 'default';
case Noop = 'noop';
case Globals = 'globals';
}
Loading
Loading