Skip to content
Merged
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
26 changes: 26 additions & 0 deletions documentation/components/bridges/symfony-telemetry-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>` — e.g. `flow.telemetry.exporter.otlp`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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<string, mixed> $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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
];
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
<span class="sf-toolbar-status">{{ collector.spanCount }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Span time</b>
<span>{{ collector.totalDurationMs|number_format(2) }} ms</span>
<b>Trace time</b>
<span>{{ collector.timelineDurationMs|number_format(2) }} ms</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Metrics</b>
Expand Down Expand Up @@ -65,8 +65,8 @@
<span class="label">Spans</span>
</div>
<div class="metric">
<span class="value">{{ collector.totalDurationMs|number_format(2) }} <span class="unit">ms</span></span>
<span class="label">Total span time</span>
<span class="value">{{ collector.timelineDurationMs|number_format(2) }} <span class="unit">ms</span></span>
<span class="label">Total trace time</span>
</div>
<div class="metric">
<span class="value">{{ collector.metricCount }}</span>
Expand Down Expand Up @@ -118,7 +118,7 @@
{% endfor %}
</tbody>
</table>
<p class="text-small text-muted">Total window: {{ collector.timelineDurationMs|number_format(2) }} ms · bars are positioned relative to the first captured span.</p>
<p class="text-small text-muted">Bars are positioned relative to the first captured span.</p>
{% endif %}

<h3>Spans</h3>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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([
Expand Down
Loading
Loading