traceway/opentelemetry-symfony
Symfony integration for OpenTelemetry that adds tracing and instrumentation to your applications. Capture HTTP requests, database queries, and service calls, then export spans via OTLP to observability backends such as Jaeger, Tempo, or Traceway for performance monitoring and debugging.
Three steps to a first trace:
composer require traceway/opentelemetry-symfony
# production: also install the C extensions
pecl install protobuf grpc # ext-protobuf strongly recommended; ext-grpc only for OTLP/gRPC
Register the bundle in config/bundles.php, then point it at a collector. The config root follows the package name — confirm the exact alias in the README, but it will look like:
# config/packages/traceway_opentelemetry.yaml
traceway_opentelemetry:
service_name: my-app
exporter:
endpoint: '%env(OTEL_EXPORTER_OTLP_ENDPOINT)%' # http://localhost:4318
protocol: http/protobuf
traces:
sampler: parent_based
metrics:
enabled: false # opt-in, leave off until you need it
Run an OTel Collector locally (or point straight at Jaeger/Tempo/Grafana Alloy) and hit one endpoint. Look for two things first: the service.name resource attribute is set (not unknown_service), and a single HTTP span exists for your route with child spans hanging off it.
First use case — the slow-endpoint waterfall. Pick your worst p95 route and open it in the trace UI. Because HTTP, Doctrine DBAL, Cache, Twig and outbound HTTP are all auto-instrumented, you get a breakdown for free: 400ms in five DB queries, 900ms in one Guzzle call, 60ms rendering product/show.html.twig. That is the whole value proposition on day one — no code changes, just a timeline. Note that as the Symfony-equivalent of dropping Telescope or Clockwork on a route, except it survives production and crosses service boundaries.
Baseline before custom code. The bundle instruments the kernel, console, Messenger, Scheduler, Mailer, DBAL, Cache, Twig and outbound clients automatically. Spend the first day reading traces before writing any. Most teams discover half their latency is a cache stampede or an N+1 the profiler never surfaced.
Cross-service propagation is the killer feature. Outgoing calls through Symfony HttpClient, Guzzle and PSR-18 clients get a W3C traceparent injected by decorator, so a frontend → API → worker chain renders as one trace, provided the downstream service is also instrumented. Verify the header actually leaves:
# on the receiving side, or in a debug proxy
curl -s localhost:8080/api/foo -H 'traceparent: 00-<trace-id>-<span-id>-01'
Messenger: instrument the envelope, not the handler. The bundle creates producer spans at dispatch and consumer spans on handling, propagating context through message headers. This separates queue wait from processing time — the two numbers that matter when async work "feels slow". If you hand-roll a transport or a custom async boundary (Redis list, cron-triggered job), you must copy the traceparent header yourself or the trace breaks.
Custom spans for business logic. Inject the tracer and wrap the unit of work that matters — payment capture, PDF generation, a multi-step import:
$span = $this->tracer->spanBuilder('invoice.generate')->startSpan();
$span->setAttribute('invoice.id', $id);
try {
// work
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(Status::STATUS_ERROR);
throw $e;
} finally {
$span->end();
}
Add high-value attributes once at the edge — user id, tenant, plan, feature flag — via a kernel listener or the bundle's request-attribute hook, then they appear on every child span's trace view. Resist adding attributes inside loops.
Log correlation. Enable the Monolog processor and formatter so trace_id/span_id land in every log line. This is the pattern that actually changes day-to-day debugging: error in Sentry or a log search → copy trace_id → open the full waterfall. Make sure the processor sits before the handler in the Monolog stack, and that JSON logs keep the fields as strings.
Metrics: opt in narrowly. Turn on metrics only for a handful of golden signals (request duration histogram, queue depth, cache hit ratio). Cardinality is the failure mode — never label by user id, URL with IDs, or SQL text.
Environment split. Dev: console or simple exporter, always_on sampler, full DB statements. Prod: OTLP batch exporter, parent_based + ratio sampler, redacted attributes, health-check routes excluded. Keep one source of truth — either Symfony config or OTEL_* env vars, not both, because the precedence will bite you.
Testing. Disable export in test env (OTEL_SDK_DISABLED=true) so CI isn't noisy, but add a couple of tests against an in-memory exporter to assert a span name and key attribute exist for critical flows.
Laravel-developer translation: a bundle is a package plus a service provider; config/packages/*.yaml is config/*.php; the decorators this bundle registers are the container bindings you'd normally write by hand in a service provider. The mental model transfers cleanly.
messenger:consume never fires kernel.terminate per message, so a batch span processor may hold spans indefinitely. Set OTEL_BSP_SCHEDULE_DELAY, cap OTEL_BSP_MAX_QUEUE_SIZE, and restart workers with --time-limit in production. Dropped spans in workers almost always mean the queue filled.db.statement can include bound values, and HTTP client spans can capture headers. Redact authorization, cookie, and any request/response bodies before this reaches a shared collector. Check what a "safe" query actually looks like in the UI, not in the config.parent_based, an upstream service sending sampled=0 makes your service silently produce nothing. When traces "disappear" in a multi-service setup, force always_on briefly to isolate whether it's the sampler or the exporter./_fragment, /_profiler, /_wdt and health checks or your traces are permanently noisy.service.name, service.version and deployment.environment are what make traces filterable. Without them you get unknown_service and can't tell staging from prod.How can I help you explore Laravel packages today?