open-telemetry/symfony-sdk-bundle
Symfony bundle for integrating the OpenTelemetry PHP SDK into Symfony applications. It provides bundle configuration and container wiring for tracing, metrics, and logs, making it easier to instrument services and export telemetry through configured OpenTelemetry exporters such as OTLP.
Install the bundle and its transitive SDK dependencies:
composer require open-telemetry/symfony-sdk-bundle
Note the package name on Packagist and the GitHub repo name (contrib-sdk-bundle) do not match — read the repo README's install snippet before you assume the vendor/package string, and pin the minor version you land on.
Register it in config/bundles.php (it is not auto-registered by Flex in most setups):
return [
// ...
OpenTelemetry\Contrib\Symfony\OtelSdkBundle\OtelSdkBundle::class => ['all' => true],
];
Then dump the reference config — this is the single most important first step, because the bundle's config tree is the source of truth for key names and it is not fully documented in the README:
bin/console config:dump-reference opentelemetry
Start with a config that exports to the console so you get feedback with zero infrastructure:
# config/packages/opentelemetry.yaml
opentelemetry:
resource:
attributes:
service.name: '%env(OTEL_SERVICE_NAME)%'
deployment.environment: '%kernel.environment%'
trace:
sampler: always_on # confirm exact key via config:dump-reference
exporters:
console: ~ # swap for otlp once you have a collector
First use case: run bin/console cache:clear or hit one route and confirm you see span output (or a trace in Jaeger/Tempo/otel-tidy). If you see nothing, stop and fix that before adding any manual instrumentation — the failure modes are almost always exporter/sampler wiring, not your code.
Then move to a real backend:
opentelemetry:
trace:
sampler:
type: parent_based
root:
type: trace_id_ratio
ratio: 0.1
exporters:
otlp:
endpoint: '%env(OTEL_EXPORTER_OTLP_ENDPOINT)%'
protocol: http/protobuf
The bundle mostly auto-instruments: it wires the Symfony HttpKernel (one root span per request with route/controller/status attributes), Console commands, the Symfony HttpClient, and (depending on installed packages) Doctrine DBAL, Twig, and Messenger. Your mental model should be "the bundle gives me a request span and an outbound-call span tree for free; I add the business-meaningful spans in the middle."
Where to look when something's off, in order:
bin/console debug:container --tag=opentelemetry (adjust the tag) to see what was actually registered.config:dump-reference opentelemetry for valid keys.DependencyInjection/Configuration.php on GitHub — it is the real documentation.var/log/dev.log for the SDK's own logger output (missing PSR-18 client and bad endpoints surface here, not as exceptions).Let auto-instrumentation own the edges. Don't hand-roll a span in a controller when the bundle already emits the request span. Read the parent/child shape in your trace UI first, then add children where they add information. Typical controller-level cleanup: strip noisy spans by disabling individual instrumentations (Twig rendering usually generates more spans than it earns), and keep the DB/HTTP client spans because they're where latency actually is.
Manual spans via injected TracerProviderInterface. This is the day-to-day API you'll write:
use OpenTelemetry\API\Trace\TracerProviderInterface;
use OpenTelemetry\API\Trace\SpanKind;
final class ImportOrders
{
public function __construct(private TracerProviderInterface $tracerProvider) {}
public function __invoke(string $feedId): void
{
$span = $this->tracerProvider
->getTracer('app.import')
->spanBuilder('import.orders')
->setSpanKind(SpanKind::KIND_INTERNAL)
->setAttribute('feed.id', $feedId)
->setAttribute('feed.rows', 0)
->startSpan();
$scope = $span->activate(); // required: makes it "current" for children
try {
// work; children created here attach to $span
$span->setAttribute('feed.rows', 1234);
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$scope->detach();
$span->end();
}
}
}
The activate()/detach() pair is the part people forget; without it, child spans and auto-instrumented spans inside the block attach to the request span instead, and your trace becomes a flat list.
Add spans at the use-case layer, not the framework layer. Service classes (command handlers, message handlers, importers) are the right place: they're reachable from HTTP, CLI, and Messenger, so one span definition covers all three entry points.
Propagation across async boundaries. For Messenger and queue consumers, inject context in the producer and extract in the consumer so the trace continues rather than starting fresh — or, if the semantics are fire-and-forget, link spans instead of parenting them. The bundle handles W3C traceparent injection for the Symfony HttpClient; it does not do it for raw curl, Guzzle, or a bespoke AMQP envelope, so you inject headers manually with the propagator service for those.
Long-running consumers need explicit flushing. messenger:consume and cron workers never hit the normal shutdown path. Either flush per message in a handler, or ensure the provider is shut down on SIGTERM (BatchSpanProcessor buffers spans; a killed worker loses them silently).
Sampling strategy by environment:
| Env | Sampler | Exporter |
|---|---|---|
| dev/test | always_on |
console or in-memory for assertions |
| staging | always_on, 100% |
OTLP, short retention |
| prod | parent_based + trace_id_ratio 0.05–0.2 |
OTLP batch → collector (tail-sample there) |
Push real sampling decisions to the collector where you can; app-side trace_id_ratio is a blunt tool that will drop the interesting traces along with the boring ones.
Testing. Assert on spans in functional tests using an in-memory exporter: dispatch a request, then inspect recorded spans for name, attributes, and parent/child relationships. This catches the classic regressions — a span that got moved outside its activate() block, a renamed span that breaks dashboards, a missing error status.
Integration tips:
service.name in the resource, not in span names. One deployment = one service.http.request, import.orders), put the specifics in attributes.open-telemetry/semconv rather than string literals for http.*, db.*, messaging.*.Version skew between api, sdk, semconv, and the bundle is the number one breakage. The bundle pins ranges that drift from the SDK's fast release cadence; a stray composer update on open-telemetry/* gives you Call to undefined method inside the SDK. Pin exact versions in composer.json and upgrade deliberately, all OTel packages together.
Environment variables vs. YAML precedence will bite you. The SDK reads standard OTEL_* vars (OTEL_SERVICE_NAME, OTEL_TRACES_SAMPLER, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SDK_DISABLED) and in several places they win over bundle config, or vice versa depending on the exporter. If behaviour doesn't match your YAML, env | grep OTEL before anything else. OTEL_SDK_DISABLED=true in a Docker base image is a great way to lose an afternoon.
"No HTTP client found" from the OTLP exporter. The exporter needs PSR-18/PSR-17 implementations and does not pull them in. Fix: composer require symfony/http-client nyholm/psr7. Symptom is usually an exception at kernel boot or silent span loss logged at debug level.
Missing spans: work the checklist.
simple in CLI or flush on terminate.activate()d, so children orphaned — trace looks wrong, not empty.Sub-requests, ESI, and fragments generate their own kernel spans and can produce confusing nested trees or duplicate "request" roots. If your trace UI is noisy, filter by path or exclude fragment rendering.
Console is a span factory. Cron-heavy apps that instrument every command drown in console.command spans. Disable console instrumentation globally and enable it only where you care, or sample hard on the CLI path.
Cardinality and PII. Never use raw URLs, user IDs, or SQL-with-parameters as span names. db.statement and request bodies are the usual accidental PII leaks — sanitize before setting attributes, and be aware Doctrine instrumentation may capture statements by default.
Sync exporters are a latency tax. A blocking OTLP export on the request path adds tens of milliseconds and couples your p95 to your observability backend. Batch by default in prod; keep console/simple for dev only.
The package is pre-1.0 and lightly used (12 stars, no dependents). Treat its config surface as unstable: read Configuration.php when docs are thin, expect breaking changes between minors, and keep a fallback plan of constructing the SDK manually in a services.yaml factory if the bundle lags the SDK you need. That fallback is genuinely small — a Resource, a SpanProcessor, an exporter, a TracerProvider, then register it as TracerProviderInterface.
Extension points worth knowing:
SpanProcessor — decorate the batch processor to redact attributes or drop spans by name before export.ResourceDetector — add host/pod/version attributes beyond the env-var defaults.TracerProviderInterface — cheap global policy layer (force-disable in tests, add common attributes) without touching call sites.Jaeger exporter is gone. If you're following an old tutorial that configures a jaeger exporter, it no longer exists — send OTLP to the collector and let it fan out to Jaeger/Tempo.
How can I help you explore Laravel packages today?