traceway / opentelemetry-symfony
Pure-PHP OpenTelemetry instrumentation for Symfony — automatic HTTP, Console, HttpClient, Messenger, Doctrine DBAL, Cache, Twig tracing and Monolog log-trace correlation with response propagation, a lightweight Tracing helper, route templates, and semantic conventions. No C extension required (ext-p
Package info
github.com/tracewayapp/opentelemetry-symfony-bundle
Type:symfony-bundle
pkg:composer/traceway/opentelemetry-symfony
Requires
- php: >=8.1
- open-telemetry/api: ^1.0
- open-telemetry/context: ^1.0
- open-telemetry/sdk: ^1.0
- open-telemetry/sem-conv: ^1.0
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/event-dispatcher: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- doctrine/dbal: ^4.0
- monolog/monolog: ^3.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.0 || ^11.0 || ^13.0
- symfony/cache: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-client: ^6.4 || ^7.0 || ^8.0
- symfony/mailer: ^6.4 || ^7.0 || ^8.0
- symfony/messenger: ^6.4 || ^7.0 || ^8.0
- symfony/monolog-bundle: ^3.10 || ^4.0
- symfony/phpunit-bridge: ^6.4 || ^7.0 || ^8.0
- symfony/scheduler: ^6.4 || ^7.0 || ^8.0
- twig/twig: ^3.0 || ^4.0
Suggests
- ext-protobuf: Significantly faster protobuf serialization for OTLP export (use with OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf)
- doctrine/dbal: Required for automatic Doctrine DBAL query tracing (^3.6 || ^4.0)
- monolog/monolog: Required for log-trace correlation and OTel log export (trace_id/span_id injection + OTLP log shipping)
- open-telemetry/exporter-otlp: Required to export traces via OTLP (the most common protocol for OpenTelemetry backends)
- php-http/guzzle7-adapter: HTTP transport for the OTLP exporter (or use any PSR-18 client)
- symfony/cache: Required for automatic cache pool tracing (get/delete/invalidateTags)
- symfony/http-client: Required for automatic HttpClient outgoing request tracing
- symfony/mailer: Required for automatic Mailer instrumentation (PRODUCER spans around MailerInterface::send and CLIENT spans around the transport)
- symfony/messenger: Required for automatic Messenger job/task tracing
- symfony/monolog-bundle: Required when log_export_enabled is true — wires OtelLogHandler into Monolog's handler stack
- symfony/scheduler: Required for automatic Scheduler tracing (CONSUMER spans around RecurringMessage execution, with trigger metadata)
- twig/twig: Required for automatic Twig template rendering tracing
Conflicts
- open-telemetry/api: <1.0
- open-telemetry/sdk: <1.0
- dev-master / 1.9.x-dev
- v1.9.0
- v1.8.0
- v1.7.0
- v1.6.1
- v1.6.0
- v1.5.0
- v1.4.4
- v1.4.3
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.0
- v1.0.2
- v1.0.1
- v1.0.0
- dev-feat/phpunit-13
- dev-feat/mailer-metrics
- dev-feat/scheduler-instrumentation
- dev-feat/mailer-instrumentation
- dev-feat/log-export-attribute-prefix
- dev-fix/reset-and-cleanup-consistency
- dev-refactor/drop-redundant-channel-attribute
- dev-feat/log-export-code-attributes
- dev-bug/async-response-bug
- dev-feat/dbal-operation-target-span-name
- dev-test/improve-coverage
- dev-feat/log-export
- dev-bug/support-namespacedpoolinterface
- dev-feat/dbal-3-support
- dev-bug/messenger-orphaned-spans
- dev-feat/traceway
- dev-test-lockable
- dev-dist-trace-id
This package is not auto-updated.
Last update: 2026-05-15 08:40:05 UTC
README
OpenTelemetry Symfony Bundle
Pure-PHP OpenTelemetry instrumentation for Symfony — automatic tracing for HTTP, Console, HttpClient, Messenger, Mailer, Scheduler, Doctrine DBAL, Cache, and Twig, plus Monolog log-trace correlation, OpenTelemetry log export, and opt-in metrics for Messenger, DBAL, HTTP server/client, and Mailer. No C extension required.
Works with any OpenTelemetry-compatible backend: Traceway, Jaeger, Zipkin, Datadog, Grafana Tempo, Honeycomb, and more.
- Pure PHP — no C extension required; installs on every managed Symfony host
- Production-ready — stable since v1.0, PHPStan level 10 with no baseline, supports Symfony 6.4 LTS through 8.x
- Correct under load — Messenger trace context propagates across async queue boundaries, Doctrine DBAL 3 and 4 both CI-tested, re-entrance guards prevent export-path recursion in HttpClient and the log handler
Quick Start
composer require traceway/opentelemetry-symfony
OTEL_PHP_AUTOLOAD_ENABLED=true OTEL_SERVICE_NAME=my-symfony-app OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 OTEL_EXPORTER_OTLP_PROTOCOL=http/json # Optional: OTEL_RESOURCE_ATTRIBUTES=service.version=1.0
Use
http/jsonunless you haveext-protobufinstalled — see Performance.
With Symfony Flex the bundle auto-registers; without Flex, add Traceway\OpenTelemetryBundle\OpenTelemetryBundle::class => ['all' => true] to config/bundles.php.
That's it. Every HTTP request, console command, outgoing call, Messenger job, DB query, cache operation, and Twig render is now traced.
What Gets Traced
| Component | Span Kind | What's captured |
|---|---|---|
| HTTP requests | SERVER | Route templates (GET /api/items/{id}), status codes, body sizes, client IP, exceptions, sub-requests |
| Console commands | SERVER | Command name, arguments, exit code, exceptions |
| HttpClient | CLIENT | Outgoing requests with W3C context propagation, OTLP endpoint auto-excluded, re-entrance guard |
| Messenger | PRODUCER/CONSUMER | Message class, transport, W3C context propagation across async boundaries |
| Scheduler | CONSUMER | Per scheduled-task run: schedule name, trigger expression, next-run, cancellation marker. Requires symfony/scheduler. Messenger spans for scheduled envelopes are suppressed automatically |
| Mailer | PRODUCER + CLIENT | Two-span split: PRODUCER on MailerInterface::send and CLIENT on the transport. Recipient count, message-id, X-Transport routing. Subject opt-in. Requires symfony/mailer |
| Doctrine DBAL | CLIENT | SQL queries (parameterized), transactions, db system/namespace auto-detection. DBAL 3.6+ and 4.x both CI-tested |
| Cache | INTERNAL | get (hit/miss), delete, invalidateTags with pool name. Requires symfony/cache |
| Twig | INTERNAL | Template name, nested includes. Requires twig/twig |
| Monolog | — | Inject trace_id + span_id into every log record (monolog/monolog). Opt-in OTel Logs API export with per-channel instrumentation scope (symfony/monolog-bundle, off by default) |
Also: Server-Timing response headers, full OTel semantic conventions.
Configuration
All options are optional — the bundle works out of the box with zero configuration. Create config/packages/open_telemetry.yaml to customize:
open_telemetry: traces_enabled: true tracer_name: 'opentelemetry-symfony' excluded_paths: [/health, /_profiler, /_wdt] record_client_ip: true # disable for GDPR error_status_threshold: 500 # 400-599 console_enabled: true console_excluded_commands: [cache:clear, assets:install] http_client_enabled: true http_client_excluded_hosts: [] # OTLP endpoint is auto-excluded messenger_enabled: true messenger_root_spans: false # true = standalone traces per consumed message scheduler_enabled: true # suppresses parallel Messenger spans for scheduled tasks mailer_enabled: true mailer_record_subject: false # subjects can be PII doctrine_enabled: true doctrine_record_statements: true # false = hide SQL from spans cache_enabled: true cache_excluded_pools: [cache.system, cache.validator, cache.serializer] twig_enabled: true twig_excluded_templates: ['@WebProfiler/', '@Debug/'] monolog_enabled: true # inject trace_id/span_id into log records log_export_enabled: false # OTel Logs API export (requires symfony/monolog-bundle) log_export_level: debug log_export_capture_code_attributes: false # fallback debug_backtrace when IntrospectionProcessor is absent log_export_unprefixed_attributes: false # emit context/extra as flat attributes (default flips in v2.0) metrics: # nested today; flat keys above migrate to nested in v2.0 enabled: false meter_name: 'opentelemetry-symfony' messenger: enabled: false excluded_queues: [] doctrine: enabled: false http_server: enabled: false excluded_paths: [] # same prefix-match rules as tracing excluded_paths http_client: enabled: false excluded_hosts: [] # OTLP endpoint is auto-excluded mailer: enabled: false
Environment Variables
| Variable | Example | Description |
|---|---|---|
OTEL_PHP_AUTOLOAD_ENABLED |
true |
Enable SDK auto-initialization |
OTEL_SERVICE_NAME |
my-symfony-app |
Service name shown in your backend |
OTEL_TRACES_EXPORTER |
otlp |
Traces exporter (otlp, zipkin, console, none) |
OTEL_LOGS_EXPORTER |
otlp |
Logs exporter (otlp, console, none) — only used when log_export_enabled: true |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4318 |
Collector/backend endpoint |
OTEL_EXPORTER_OTLP_PROTOCOL |
http/json |
Protocol (http/json, http/protobuf, grpc) |
See the OpenTelemetry SDK docs for all available options.
Manual Instrumentation
Inject TracingInterface for one-liner span creation:
use Traceway\OpenTelemetryBundle\TracingInterface; class OrderService { public function __construct(private readonly TracingInterface $tracing) {} public function process(int $orderId): void { $this->tracing->trace('order.validate', function () use ($orderId) { // validation logic... }); $this->tracing->trace('order.fulfill', function () { $this->tracing->trace('inventory.reserve', fn () => $this->reserve()); $this->tracing->trace('payment.charge', fn () => $this->charge()); }); } }
Mock in tests with $this->createStub(TracingInterface::class) and have trace() invoke the callback directly.
Metrics
Off by default. Enable to export OpenTelemetry metrics alongside traces, with opt-in automatic instrumentation for Messenger, Doctrine DBAL, HTTP server/client, and Mailer.
open_telemetry: metrics: enabled: true meter_name: 'opentelemetry-symfony' messenger: enabled: true excluded_queues: [] doctrine: enabled: true
What Gets Measured
| Instrument | Kind | Unit | Source | Attributes |
|---|---|---|---|---|
messaging.process.duration |
Histogram | s |
Messenger consume | messaging.system, messaging.operation.name, messaging.operation.type, messaging.destination.name, error.type on failure |
messaging.client.consumed.messages |
Counter | {message} |
Messenger consume | Same as above |
messaging.client.operation.duration |
Histogram | s |
Messenger dispatch | Same shape, messaging.operation.{name,type} = send, destination derived from SentStamp::getSenderAlias() (falls back to sender FQCN) |
messaging.client.sent.messages |
Counter | {message} |
Messenger dispatch | Same as above |
db.client.operation.duration |
Histogram | s |
DBAL connection | db.system.name, db.namespace, server.address, server.port, db.operation.name, db.collection.name (when extractable), error.type on failure |
http.server.request.duration |
Histogram | s |
HTTP server | http.request.method, url.scheme, http.route if matched, http.response.status_code, server.address, server.port, error.type on failure |
http.server.active_requests |
UpDownCounter | {request} |
HTTP server | http.request.method, url.scheme, server.address, server.port |
http.server.request.body.size |
Histogram | By |
HTTP server | Same as duration (emitted when Content-Length is set) |
http.server.response.body.size |
Histogram | By |
HTTP server | Same as duration (emitted when Content-Length is set) |
Names and attributes follow OTel semantic conventions (messaging, database, HTTP). http.server.request.duration and error.type are Stable; the rest are Development.
- HTTP server — only main requests are measured; sub-requests are covered by the main duration. Service identity comes from the OTel resource (
OTEL_SERVICE_NAME,OTEL_RESOURCE_ATTRIBUTES), not from metric name prefixing. - Messenger —
excluded_queuesmatches the transport name on both sides (ReceivedStamp::getTransportName()on consume,SentStamp::getSenderAlias()on dispatch). A dispatched envelope landing on multiple transports emits one point per non-excluded transport. - DBAL — records duration for
Connection::query()/exec(), preparedStatement::execute(), and transaction control methods. SQL text is never recorded — only the leading keyword (db.operation.name) and the primary table when extractable (db.collection.name).
HTTP Client (outgoing requests):
| Instrument | Kind | Unit | Stability | Attributes |
|---|---|---|---|---|
http.client.request.duration |
Histogram | s |
Stable | http.request.method, server.address, server.port, url.scheme, http.response.status_code on response, error.type on transport failure |
http.client.request.body.size |
Histogram | By |
Development | Same as duration (emitted when Content-Length header or a string body is present) |
http.client.response.body.size |
Histogram | By |
Development | Same as duration (emitted when response Content-Length is set or the body is fully read) |
http_client.excluded_hosts skips matching hostnames; the OTLP endpoint (from OTEL_EXPORTER_OTLP_ENDPOINT) is always auto-excluded to prevent instrumentation loops.
Mailer (outbound transport sends):
| Instrument | Kind | Unit | Stability | Attributes |
|---|---|---|---|---|
messaging.client.operation.duration |
Histogram | s |
Development | messaging.system=symfony_mailer, messaging.operation.name=send, messaging.operation.type=send, messaging.destination.name from X-Transport header when present, error.type on failure |
messaging.client.sent.messages |
Counter | {message} |
Development | Same as duration |
Decoration sits inside TraceableTransports so metric points record while the trace span is still active — backends that support exemplars can link directly from a metric data point to the corresponding trace.
Manual Metrics
Inject MeterRegistryInterface to record your own counters, histograms, and up/down counters without touching the MeterProvider directly:
use OpenTelemetry\API\Metrics\CounterInterface; use Traceway\OpenTelemetryBundle\Metrics\MeterRegistryInterface; final class MediaDownloader { private readonly CounterInterface $downloads; public function __construct(MeterRegistryInterface $metrics) { $this->downloads = $metrics->counter( 'media.download.count', description: 'Media downloads by outcome', ); } public function download(string $url): void { try { // ... download logic $this->downloads->add(1, ['outcome' => 'success']); } catch (\Throwable $e) { $type = $e::class; if (str_contains($type, '@anonymous')) { $type = get_parent_class($e) ?: \Throwable::class; } $this->downloads->add(1, ['outcome' => 'error', 'error.type' => $type]); throw $e; } } }
The registry caches instruments per name, so repeated ->counter('x') calls return the same instance. When the OTel SDK is not configured, the NoOp meter provider returns no-op instruments — safe to inject unconditionally. The @anonymous guard above normalises anonymous-class names to their parent; otherwise $e::class embeds a filesystem path, leaking code locations and exploding label cardinality.
Metrics Environment Variables
| Variable | Example | Description |
|---|---|---|
OTEL_METRICS_EXPORTER |
otlp |
Metrics exporter (otlp, console, none) — only used when metrics.enabled: true |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT |
http://localhost:4318/v1/metrics |
Override the generic OTEL_EXPORTER_OTLP_ENDPOINT for metrics |
Performance
Near-zero overhead when the SDK is inactive — every component short-circuits via isEnabled(). When tracing is on, almost all cost is in span export, not instrumentation. PHP-FPM has no background thread, so BatchSpanProcessor flushes during request shutdown.
Use http/json unless you have ext-protobuf installed. PHP's native json_encode() is faster than the pure-PHP protobuf encoder, which adds significant CPU overhead under load. Switch to http/protobuf only with the C extension installed.
For high-traffic apps:
- Run a local OTel Collector at
localhost:4318(sub-ms latency) and let it forward asynchronously. - Enable head sampling:
OTEL_TRACES_SAMPLER=parentbased_traceidratio+OTEL_TRACES_SAMPLER_ARG=0.1. - Use
excluded_paths/cache_excluded_poolsto drop noisy spans.
Contributing
git clone https://github.com/tracewayapp/opentelemetry-symfony-bundle.git
cd opentelemetry-symfony-bundle
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse