flow-php/symfony-telemetry-bundle
A Symfony bundle that integrates Flow PHP's telemetry with Symfony applications, providing tracing and instrumentation for data pipelines through OpenTelemetry. It auto-configures services, span processors, and exporters so you can observe ETL workloads with minimal setup.
Install and enable
composer require flow-php/symfony-telemetry-bundle
There is no Flex recipe for this package — expect to wire it by hand. If you're on Symfony Flex, config/bundles.php may not get the entry; add it yourself:
// config/bundles.php
return [
// ...
Flow\Symfony\TelemetryBundle\FlowTelemetryBundle::class => ['all' => true],
];
Where to look first (in this order)
README.md — for a 0-star package this is often the only real documentation. Read it end to end before writing config.vendor/flow-php/symfony-telemetry-bundle/src/DependencyInjection/Configuration.php — the authoritative schema for every config key. Don't guess YAML keys; this file is the manual.src/DependencyInjection/*Extension.php — tells you which services are aliased, which are tagged, and whether autoconfiguration is enabled.src/Resources/config/*.yaml — the actual service definitions, including any compiler passes / tagged-collector patterns.composer.json of both the bundle and flow-php/telemetry — this reveals whether you need a PSR-18 client, a PSR-17 factory, or the opentelemetry PHP extension.Minimal working config
# config/packages/flow_telemetry.yaml
flow_telemetry:
enabled: true
service_name: 'my-app'
exporter: 'console' # swap for 'otlp' in prod
First use case — trace one ETL pipeline
Flow PHP's value is ETL; the telemetry bundle's whole point is making those pipelines observable inside a Symfony app. Start here rather than with generic HTTP tracing:
// src/Command/ImportProductsCommand.php
#[AsCommand(name: 'app:import-products')]
final class ImportProductsCommand extends Command
{
public function __construct(
private readonly FlowContext $flow, // bundle-provided context/registry
private readonly Telemetry $telemetry, // flow-php/telemetry contract
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$rows = $this->flow->run(
flow()
->read(from_csv('var/import/products.csv'))
->withEntry('price', ref('price')->cast('float'))
->write(to_parquet('var/import/products.parquet'))
->run()
);
$output->writeln("Processed {$rows}");
return Command::SUCCESS;
}
}
Verify it actually emits something before pointing it at a real collector:
bin/console app:import-products -vvv
# with exporter: console you should see spans/durations printed to stdout
If you see spans locally, the rest of the work is exporter configuration, not integration.
The most common mistake is wrapping ->run() in your own manual span. Flow's telemetry is designed to be injected into the ETL Config so every processor, reader, and writer gets its own child span automatically.
use Flow\ETL\Config;
use Flow\ETL\Flow;
$config = Config::default()->withTelemetry($this->telemetry);
(new Flow($config))
->read(from_csv('...'))
->write(to_parquet('...'))
->run();
One parent span per pipeline run, children per step, and row-level metrics for free. Wrap once at the entry point; don't decorate individual processors unless you need business-specific attributes.
For pipelines triggered by HTTP (upload → transform → download), bind the trace to the request so a user-reported error is searchable by trace ID. A small kernel subscriber that adds the trace ID to the response is usually enough:
final class TraceHeaderSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly Telemetry $telemetry) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => 'onResponse'];
}
public function onResponse(ResponseEvent $event): void
{
if (! $event->isMainRequest()) {
return;
}
$event->getResponse()->headers->set(
'X-Trace-Id',
$this->telemetry->currentTraceId() ?? '-'
);
}
}
Now your front-end error reports carry a join key into your tracing backend — same idea as Laravel's X-Request-Id correlation, but real distributed tracing.
Anything dispatched to Messenger loses the request context unless you propagate it. If the bundle ships a middleware, tag it and register it on the bus; if it doesn't, this is the one custom class you'll write. Serialize the trace context into the envelope stamps on dispatch, restore it on consume, and flush the exporter at the end of the handler — long-running workers batch spans and you will silently lose the last N of them otherwise.
# config/packages/messenger.yaml
framework:
messenger:
buses:
command_bus:
middleware:
- flow_telemetry.messenger.middleware
Mirror the Laravel Telescope/Horizon instinct — cheap, local, verbose in dev; sampled and batched in prod.
# config/packages/flow_telemetry.yaml
flow_telemetry:
enabled: true
service_name: '%env(APP_NAME)%'
exporter: '%env(TELEMETRY_EXPORTER)%' # console | otlp | memory
trace:
sample_rate: '%env(float:TELEMETRY_SAMPLE_RATE)%'
# .env.local
TELEMETRY_EXPORTER=console
TELEMETRY_SAMPLE_RATE=1.0
# .env.production
TELEMETRY_EXPORTER=otlp
TELEMETRY_SAMPLE_RATE=0.1
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
Sampling at 10% in prod is not a compromise — it's the difference between a working collector and one that OOMs. Sample head-based unless you have a specific reason not to.
Spans answer "why was this run slow". Counters/histograms answer "how many runs failed this week". Emit both, but keep metric attributes low-cardinality:
$this->telemetry->counter('etl.rows_processed', $rows, [
'pipeline' => 'products_import', // low cardinality
'status' => 'success',
]);
// NOT: 'file' => $filename — this will melt your metrics backend
The rule of thumb: span attributes may be high-cardinality (IDs, filenames), metric attributes must not be.
Don't let tests hit a collector. Configure the in-memory/test exporter in config/packages/test/flow_telemetry.yaml and assert on spans:
public function test_import_emits_a_span_per_step(): void
{
$this->commandTester->execute(['command' => 'app:import-products']);
$spans = $this->telemetry->exportedSpans(); // or whatever the test exporter exposes
self::assertNotEmpty($spans);
self::assertSame('products.parquet', $spans[0]->attributes()['output'] ?? null);
}
If the bundle has no test exporter, exporter: 'memory' (when present) is the seam — verify the exact key in Configuration.php.
Zero stars, zero dependents means you are the QA. Pin the exact version ("flow-php/symfony-telemetry-bundle": "0.x.y"), commit composer.lock, and read the src/ tree on every upgrade. There is no ecosystem of blog posts to fall back on; the vendor source is your documentation.
No Flex recipe → no auto-config. You get a plain bundle: no config/packages/flow_telemetry.yaml scaffold, no .env stubs. If the config key isn't obvious, grep the Configuration.php ->rootNode()/->children() chain — Symfony will also list valid keys in the error message on an invalid node, which is a fast way to discover the schema.
Exporter flush on process exit is the #1 silent bug. Batch exporters buffer spans; kernel.terminate and CLI command completion are the flush points. Symptoms: traces appear minutes late, or the last span of every run is missing (often the root span — making the whole trace invisible in your UI). Check the bundle for a terminate listener; if it's absent, register one.
PHP extension vs SDK autoloading conflict. If ext-opentelemetry is installed and the SDK's _register_autoloader() runs, you get duplicate instrumentation and doubled spans. Pick one path per environment and make it explicit in php.ini/Docker images rather than relying on load order.
Double instrumentation with other bundles. If you also run an OTel Symfony bundle or symfony/http-client tracing, HTTP and DB calls get instrumented twice. Check span counts on a single request before blaming the ETL code.
Messenger workers are long-lived. Memory grows with buffered spans; set a --time-limit and --memory-limit on messenger:consume, and verify spans actually arrive between messages rather than only when the worker dies.
Context loss across dispatch() is silent. No exception, just orphan traces. Test it explicitly: dispatch a message, then confirm the consumer span shares the trace ID of the dispatching request. This is the single highest-value integration test you can write for this bundle.
PSR-17/PSR-18 dependency gaps. The OTLP/HTTP exporter needs an HTTP client and request factory. If none is installed, you'll get a container-time service-not-found for Psr\Http\Client\ClientInterface, not a helpful runtime message about telemetry.
Sampling interacts with child spans. Head-based sampling decisions are made at the root; don't add per-step sampling or you'll get broken traces with missing parents.
Config key naming is the most likely friction point. Bundles like this frequently register under flow_telemetry: while docs say flow: (or vice versa), and the extension alias is defined in the Extension class, not the bundle class. Check getAlias() first when YAML silently does nothing — Symfony ignores unknown top-level keys in files for other bundles' namespaces.
Disable in tests and in cache:warmup/lint:container CI jobs. A misconfigured exporter turns a container build into an HTTP timeout. Gate the bundle with enabled: '%env(bool:TELEMETRY_ENABLED)%' and default it to false in the test env.
You are not getting Laravel-style ergonomics. No facade, no Telescope-like UI, no php artisan telemetry:prune. The payoff is that Flow's pipeline internals emit spans you'd otherwise hand-roll — read the vendor source once, wrap the two seams (Flow Config, Messenger middleware), and keep everything else stock Symfony.
How can I help you explore Laravel packages today?