diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index 858a1fb48..b4a3ab8e4 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -316,6 +316,32 @@ flow_telemetry: | `memory` | none | Stores batches in memory (testing) | | `void` | none | Discards everything (no-op) | +Every exporter also accepts an `enabled` flag (default `true`). When it resolves to `false`, the exporter does not +ship anything to its backend, but **profiler capture and all other exporters keep working** โ€” so telemetry stays +visible in the Symfony profiler without being sent to a collector. + +The flag accepts a literal boolean or an environment variable: + +- A literal `enabled: false` is resolved at compile time and replaces the exporter with a no-op (`void`); the real + backend (e.g. the OTLP transport) is never built. +- An `enabled: '%env(bool:OTEL_ENABLED)%'` is resolved per request at runtime: the real exporter is wrapped so the + env var gates export on or off without rebuilding the container. The backend is still built, so a declared `otlp` + exporter requires `flow-php/telemetry-otlp-bridge` to be installed even while the flag is off. + +The exporter still declares its real sub-block in both cases, so toggling it back on needs no other change. + +```yaml +flow_telemetry: + exporters: + otlp: + enabled: '%env(bool:OTEL_ENABLED)%' # off โ†’ discard exports; profiler still captures every signal + otlp: + transport: + type: curl + endpoint: '%env(OTEL_ENDPOINT)%' + encoding: protobuf +``` + Service IDs registered by the bundle (predictable for `decorates:`): - `flow.telemetry.exporter.` โ€” e.g. `flow.telemetry.exporter.otlp` diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php index 78b671335..efd926b85 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php @@ -72,6 +72,7 @@ use Flow\Telemetry\Propagation\W3CBaggage; use Flow\Telemetry\Propagation\W3CTraceContext; use Flow\Telemetry\Provider\Clock\SystemClock; +use Flow\Telemetry\Provider\Conditional\ConditionalExporter; use Flow\Telemetry\Provider\Console\ConsoleExporter; use Flow\Telemetry\Provider\Memory\MemoryExporter; use Flow\Telemetry\Provider\Memory\MemoryLogProcessor; @@ -2117,6 +2118,10 @@ private function exportersNode(): ArrayNodeDefinition ->thenInvalid('Exporter must declare exactly one of: otlp, service, console, memory, void.') ->end() ->children() + ->booleanNode('enabled') + ->info('When false, this exporter is replaced by a no-op (void) exporter: nothing is exported to its backend, while profiler capture and every other exporter keep working. Set it per environment to keep telemetry in the profiler without shipping it to a collector. Defaults to true.') + ->defaultTrue() + ->end() ->append($this->otlpExporterNode()) ->append($this->serviceExporterNode()) ->arrayNode('console') @@ -3122,71 +3127,115 @@ private function registerNamedExporters(array $config, ContainerBuilder $builder { foreach ($config as $name => $exporterConfig) { $serviceId = 'flow.telemetry.exporter.' . $name; + // @mago-expect analysis:mixed-assignment + $enabled = $exporterConfig['enabled'] ?? true; - if (array_key_exists('void', $exporterConfig)) { + // A literal false is known at compile time, so skip building the real backend entirely. + if ($enabled === false) { $builder->setDefinition($serviceId, new Definition(VoidExporter::class)); continue; } - if (array_key_exists('memory', $exporterConfig)) { - $builder->setDefinition($serviceId, new Definition(MemoryExporter::class)); + $this->registerExporterDefinition($name, $serviceId, $exporterConfig, $builder); - continue; + // Anything that is not a literal true is an unresolved env placeholder (e.g. %env(bool:...)%); + // wrap the real exporter so the flag is honored at runtime instead of compile time. + if ($enabled !== true) { + $this->wrapExporterWithRuntimeToggle($serviceId, $enabled, $builder); } + } + } - if (array_key_exists('console', $exporterConfig)) { - $builder->setDefinition($serviceId, new Definition(ConsoleExporter::class)); + /** + * @param array $exporterConfig + */ + private function registerExporterDefinition( + string $name, + string $serviceId, + array $exporterConfig, + ContainerBuilder $builder, + ): void { + if (array_key_exists('void', $exporterConfig)) { + $builder->setDefinition($serviceId, new Definition(VoidExporter::class)); - continue; - } + return; + } - if (array_key_exists('service', $exporterConfig)) { - // @mago-expect analysis:mixed-assignment - $customServiceId = $exporterConfig['service']['id'] ?? null; + if (array_key_exists('memory', $exporterConfig)) { + $builder->setDefinition($serviceId, new Definition(MemoryExporter::class)); - if (!is_string($customServiceId) || $customServiceId === '') { - throw new RuntimeException(sprintf('exporter "%s" of type "service" requires "service.id"', $name)); - } - $builder->setAlias($serviceId, $customServiceId); + return; + } - continue; - } + if (array_key_exists('console', $exporterConfig)) { + $builder->setDefinition($serviceId, new Definition(ConsoleExporter::class)); - if (array_key_exists('otlp', $exporterConfig)) { - $builder->setParameter('flow.telemetry.otlp_configured', true); - // @mago-expect analysis:mixed-assignment - $transportConfig = $exporterConfig['otlp']['transport'] ?? null; + return; + } - if (!is_array($transportConfig) || count($transportConfig) === 0) { - throw new RuntimeException(sprintf( - 'exporter "%s" of type "otlp" requires an inline "transport" configuration', - $name, - )); - } - $errorHandlerRef = $this->resolveErrorHandlerReference( - $exporterConfig['otlp']['error_handler'] ?? 'default', - $builder, - ); - $transportServiceId = $this->buildEmbeddedOtlpTransport( - $name, - $transportConfig, - $builder, - errorHandlerRef: $errorHandlerRef, - ); - $definition = new Definition(OTLPExporter::class); - $definition->setArgument(0, new Reference($transportServiceId)); - $definition->setArgument(1, $errorHandlerRef); - $builder->setDefinition($serviceId, $definition); + if (array_key_exists('service', $exporterConfig)) { + // @mago-expect analysis:mixed-assignment + $customServiceId = $exporterConfig['service']['id'] ?? null; - continue; + if (!is_string($customServiceId) || $customServiceId === '') { + throw new RuntimeException(sprintf('exporter "%s" of type "service" requires "service.id"', $name)); } + $builder->setAlias($serviceId, $customServiceId); - throw new RuntimeException(sprintf( - 'exporter "%s" must declare exactly one of: otlp, service, console, memory, void', + return; + } + + if (array_key_exists('otlp', $exporterConfig)) { + $builder->setParameter('flow.telemetry.otlp_configured', true); + // @mago-expect analysis:mixed-assignment + $transportConfig = $exporterConfig['otlp']['transport'] ?? null; + + if (!is_array($transportConfig) || count($transportConfig) === 0) { + throw new RuntimeException(sprintf( + 'exporter "%s" of type "otlp" requires an inline "transport" configuration', + $name, + )); + } + $errorHandlerRef = $this->resolveErrorHandlerReference( + $exporterConfig['otlp']['error_handler'] ?? 'default', + $builder, + ); + $transportServiceId = $this->buildEmbeddedOtlpTransport( $name, - )); + $transportConfig, + $builder, + errorHandlerRef: $errorHandlerRef, + ); + $definition = new Definition(OTLPExporter::class); + $definition->setArgument(0, new Reference($transportServiceId)); + $definition->setArgument(1, $errorHandlerRef); + $builder->setDefinition($serviceId, $definition); + + return; } + + throw new RuntimeException(sprintf( + 'exporter "%s" must declare exactly one of: otlp, service, console, memory, void', + $name, + )); + } + + private function wrapExporterWithRuntimeToggle(string $serviceId, mixed $enabled, ContainerBuilder $builder): void + { + $innerId = $serviceId . '.toggle.inner'; + + if ($builder->hasAlias($serviceId)) { + $builder->setAlias($innerId, $builder->getAlias($serviceId)); + $builder->removeAlias($serviceId); + } else { + $builder->setDefinition($innerId, $builder->getDefinition($serviceId)); + } + + $definition = new Definition(ConditionalExporter::class); + $definition->setArgument(0, $enabled); + $definition->setArgument(1, new Reference($innerId)); + $builder->setDefinition($serviceId, $definition); } /** diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Profiler/FlowTelemetryDataCollector.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Profiler/FlowTelemetryDataCollector.php index 1f2bc796c..11c3cc71b 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Profiler/FlowTelemetryDataCollector.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Profiler/FlowTelemetryDataCollector.php @@ -17,8 +17,6 @@ use Throwable; use function array_key_exists; -use function array_map; -use function array_sum; use function count; use function is_float; use function max; @@ -56,7 +54,6 @@ public function lateCollect(): void 'spans' => $spans, 'metrics' => $this->normalizeMetrics($this->exporter->metrics()), 'logs' => $this->normalizeLogs($this->exporter->logs()), - 'totalDurationMs' => array_sum(array_map(static fn(array $s): float => $s['durationMs'] ?? 0.0, $spans)), 'timelineDurationMs' => $timelineDurationMs, ]; } @@ -119,14 +116,6 @@ public function getSignalCount(): int return $this->getSpanCount() + $this->getMetricCount() + $this->getLogCount(); } - public function getTotalDurationMs(): float - { - // @mago-expect analysis:mixed-assignment - $value = $this->data['totalDurationMs'] ?? 0.0; - - return is_float($value) ? $value : 0.0; - } - public function getTimelineDurationMs(): float { // @mago-expect analysis:mixed-assignment diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/views/Collector/telemetry.html.twig b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/views/Collector/telemetry.html.twig index 7bca35b05..9c6610e82 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/views/Collector/telemetry.html.twig +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/views/Collector/telemetry.html.twig @@ -26,8 +26,8 @@ {{ collector.spanCount }}
- Span time - {{ collector.totalDurationMs|number_format(2) }} ms + Trace time + {{ collector.timelineDurationMs|number_format(2) }} ms
Metrics @@ -65,8 +65,8 @@ Spans
- {{ collector.totalDurationMs|number_format(2) }} ms - Total span time + {{ collector.timelineDurationMs|number_format(2) }} ms + Total trace time
{{ collector.metricCount }} @@ -118,7 +118,7 @@ {% endfor %} -

Total window: {{ collector.timelineDurationMs|number_format(2) }} ms ยท bars are positioned relative to the first captured span.

+

Bars are positioned relative to the first captured span.

{% endif %}

Spans

diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php index c38ba4285..ec099ac96 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php @@ -30,6 +30,7 @@ use Flow\Telemetry\Logger\Processor\PipelineLogProcessor; use Flow\Telemetry\Meter\Processor\BatchingMetricProcessor; use Flow\Telemetry\Provider\Clock\SystemClock; +use Flow\Telemetry\Provider\Conditional\ConditionalExporter; use Flow\Telemetry\Provider\Console\ConsoleExporter; use Flow\Telemetry\Provider\Memory\MemorySpanProcessor; use Flow\Telemetry\Provider\Void\VoidExporter; @@ -39,7 +40,10 @@ use Flow\Telemetry\Resource; use Flow\Telemetry\Resource\Detector\CachingDetector; use Flow\Telemetry\Resource\Detector\GitDetector; +use Flow\Telemetry\Signal\Signals; use Flow\Telemetry\Telemetry; +use Flow\Telemetry\Tests\Mother\ExporterSpy; +use Flow\Telemetry\Tests\Mother\SpanMother; use Flow\Telemetry\Tracer\Processor\BatchingSpanProcessor; use Flow\Telemetry\Tracer\Processor\CompositeSpanProcessor; use Flow\Telemetry\Tracer\Sampler\AttributeMatchingSampler; @@ -275,6 +279,102 @@ public function test_console_exporter_is_registered(): void static::assertInstanceOf(ConsoleExporter::class, $container->get('flow.telemetry.exporter.console')); } + public function test_disabled_exporter_is_replaced_with_void_exporter(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => [ + 'otlp' => [ + 'enabled' => false, + 'otlp' => [ + 'transport' => [ + 'type' => 'curl', + 'endpoint' => 'http://localhost:4318', + 'encoding' => 'protobuf', + ], + ], + ], + ], + 'tracer_provider' => [ + 'processor' => ['type' => 'batching', 'exporter' => 'otlp'], + ], + ]); + }, + ]); + + $container = $this->getContainer(); + static::assertInstanceOf(VoidExporter::class, $container->get('flow.telemetry.exporter.otlp')); + static::assertFalse($container->has('flow.telemetry.exporter.otlp.transport')); + } + + public function test_env_bool_flag_drops_export_at_runtime_when_disabled(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => [ + 'primary' => [ + 'enabled' => '%env(bool:FLOW_TEST_OTEL_ENABLED)%', + 'service' => ['id' => 'app.spy_exporter'], + ], + ], + 'tracer_provider' => [ + 'processor' => ['type' => 'batching', 'exporter' => 'primary'], + ], + ]); + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container->setParameter('env(FLOW_TEST_OTEL_ENABLED)', '0'); + $container->setDefinition('app.spy_exporter', new Definition(ExporterSpy::class))->setPublic(true); + }); + }, + ]); + + $container = $this->getContainer(); + $exporter = $container->get('flow.telemetry.exporter.primary'); + static::assertInstanceOf(ConditionalExporter::class, $exporter); + + $spy = $container->get('app.spy_exporter'); + static::assertInstanceOf(ExporterSpy::class, $spy); + $exporter->export(Signals::traces([SpanMother::withName('span')])); + static::assertSame(0, $spy->exportedCount()); + } + + public function test_env_bool_flag_forwards_export_at_runtime_when_enabled(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => [ + 'primary' => [ + 'enabled' => '%env(bool:FLOW_TEST_OTEL_ENABLED)%', + 'service' => ['id' => 'app.spy_exporter'], + ], + ], + 'tracer_provider' => [ + 'processor' => ['type' => 'batching', 'exporter' => 'primary'], + ], + ]); + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container->setParameter('env(FLOW_TEST_OTEL_ENABLED)', '1'); + $container->setDefinition('app.spy_exporter', new Definition(ExporterSpy::class))->setPublic(true); + }); + }, + ]); + + $container = $this->getContainer(); + $exporter = $container->get('flow.telemetry.exporter.primary'); + static::assertInstanceOf(ConditionalExporter::class, $exporter); + + $spy = $container->get('app.spy_exporter'); + static::assertInstanceOf(ExporterSpy::class, $spy); + $exporter->export(Signals::traces([SpanMother::withName('span')])); + static::assertSame(1, $spy->exportedCount()); + } + public function test_curl_transport_is_built_inline_inside_otlp_exporter(): void { $this->bootKernel([ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Profiler/FlowTelemetryDataCollectorTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Profiler/FlowTelemetryDataCollectorTest.php index bba467265..076d96739 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Profiler/FlowTelemetryDataCollectorTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Profiler/FlowTelemetryDataCollectorTest.php @@ -97,17 +97,6 @@ public function test_late_collect_computes_timeline_window_from_latest_end(): vo static::assertSame(50.0, $collector->getTimelineDurationMs()); } - public function test_late_collect_totals_span_durations(): void - { - $store = $this->storeWithSpanTree(); - $collector = $this->collector($store); - - $collector->lateCollect(); - - static::assertSame(3, $collector->getSpanCount()); - static::assertSame(69.0, $collector->getTotalDurationMs()); - } - public function test_late_collect_normalizes_metric_rows(): void { $store = new MemoryExporter(); diff --git a/src/lib/telemetry/src/Flow/Telemetry/Provider/Conditional/ConditionalExporter.php b/src/lib/telemetry/src/Flow/Telemetry/Provider/Conditional/ConditionalExporter.php new file mode 100644 index 000000000..1c4dfd822 --- /dev/null +++ b/src/lib/telemetry/src/Flow/Telemetry/Provider/Conditional/ConditionalExporter.php @@ -0,0 +1,36 @@ +enabled) { + return true; + } + + return $this->exporter->export($signal); + } + + public function shutdown(): void + { + $this->exporter->shutdown(); + } +} diff --git a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Provider/Conditional/ConditionalExporterTest.php b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Provider/Conditional/ConditionalExporterTest.php new file mode 100644 index 000000000..14abf92d6 --- /dev/null +++ b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Provider/Conditional/ConditionalExporterTest.php @@ -0,0 +1,75 @@ +export($signal); + + static::assertSame([$signal], $inner->exported()); + } + + public function test_returns_inner_result_when_enabled(): void + { + static::assertFalse((new ConditionalExporter( + true, + new ExporterSpy(false), + ))->export(Signals::traces([SpanMother::withName('span')]))); + } + + public function test_drops_batch_without_calling_inner_when_disabled(): void + { + $inner = new ExporterSpy(); + + (new ConditionalExporter(false, $inner))->export(Signals::traces([SpanMother::withName('span')])); + + static::assertSame(0, $inner->exportedCount()); + } + + public function test_reports_success_when_disabled(): void + { + static::assertTrue((new ConditionalExporter( + false, + new ExporterSpy(false), + ))->export(Signals::traces([SpanMother::withName('span')]))); + } + + public function test_shutdown_delegates_to_inner_when_enabled(): void + { + $inner = new ExporterSpy(); + + (new ConditionalExporter(true, $inner))->shutdown(); + + static::assertSame(1, $inner->shutdownCount()); + } + + public function test_shutdown_delegates_to_inner_when_disabled(): void + { + $inner = new ExporterSpy(); + + (new ConditionalExporter(false, $inner))->shutdown(); + + static::assertSame(1, $inner->shutdownCount()); + } +}