Symfony Telemetry Bundle
Flow Symfony Telemetry Bundle provides automatic telemetry integration for Symfony applications, including HTTP request/response tracing, console command instrumentation, and configurable exporters through OpenTelemetry-compatible backends.
- Installation
- Overview
- Configuration Reference
- Resource Configuration
- Clock Configuration
- Context Storage
- Propagator
- Error Handlers
- Exporters (named definitions)
- TracerProvider
- MeterProvider
- LoggerProvider
- Processor Configuration
- Exporter Definitions
- OTLP Transport Configuration
- Multiple OTLP backends
- Migrating from older config
- Instrumentation
- Web Profiler
- Named Instruments
- Main Logger
- Logging Channels
- Pattern Matching
- Usage
- Complete Production Example
Installation
For detailed installation instructions, see the installation page.
Overview
This bundle is built on top of flow-php/telemetry — see that page for the
underlying Telemetry, tracer/meter/logger API, and processor/exporter primitives. For exporting to OTLP-compatible
backends, it also uses the Telemetry OTLP Bridge.
This bundle integrates Flow PHP's Telemetry library with Symfony applications. It provides:
- Automatic resource detection - Detects service name, version, environment, OS, host, and process information
- 8 auto-instrumentation options - HTTP kernel, console, messenger, Twig, HTTP client, PSR-18 client, Doctrine DBAL, and cache
- Context propagation - W3C TraceContext and Baggage support for distributed tracing
- Configurable exporters - Console, memory, void, or OTLP with multiple transport options
- Full Symfony configuration - Configure everything through Symfony's config system
Configuration Reference
Any
error_handler:field that appears under a provider, processor, orotlpexporter references a name from the top-levelerror_handlers:map (see Error Handlers). When omitted it defaults todefault, which is auto-created as{ type: error_log }if the user does not declare one.
Resource Configuration
The resource node configures OpenTelemetry Resource attributes that identify your service.
flow_telemetry:
resource:
detectors:
enabled: true # Enable resource detectors (default: true)
static:
cache:
enabled: true # Cache static attributes (default: true)
path: null # Cache file path (default: sys_get_temp_dir()/flow_telemetry_resource.cache)
os:
enabled: true # Detect os.type, os.name, os.version, os.description
host:
enabled: true # Detect host.name, host.arch, host.id
service:
enabled: true # Detect service.name, service.version from composer.json
deployment:
enabled: true # Detect deployment.environment.name from kernel environment
environment:
enabled: true # Read OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES
dynamic:
process:
enabled: true # Detect process.pid, process.runtime.*, process.executable.*
custom:
service.name: 'my-app'
service.version: '1.0.0'
deployment.environment.name: 'production'
Detector types:
| Type | Class | Category | Attributes Detected |
|---|---|---|---|
os |
OsDetector |
static | os.type, os.name, os.version, os.description |
host |
HostDetector |
static | host.name, host.arch, host.id |
service |
ComposerDetector |
static | service.name, service.version (from composer.json) |
deployment |
SymfonyDeploymentDetector |
static | deployment.environment.name (from kernel environment) |
environment |
EnvironmentDetector |
static | Reads OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES env vars |
process |
ProcessDetector |
dynamic | process.pid, process.runtime.*, process.executable.* |
Static detectors are cached by default. Dynamic detectors run on every request/command.
The cache file lives outside Symfony's cache lifecycle on purpose: building the Symfony cache
(via cache:warmup) at image build time would otherwise freeze runtime-dependent attributes
such as host.name or process.pid from the build container. Defaulting to
sys_get_temp_dir() keeps the cache per-runtime and avoids that pitfall. To invalidate it,
delete the cache file or restart the process; cache:clear does not touch it.
Custom attributes override auto-detected values.
Clock Configuration
- type:
string|null - default:
null
Custom PSR-20 clock service ID. If not provided, uses the built-in SystemClock.
flow_telemetry:
clock_service_id: 'app.clock'
Context Storage
- type:
enum - default:
memory
Context storage for maintaining trace context across async operations.
flow_telemetry:
context_storage:
type: memory # memory|service
service_id: null # Custom service ID (only for type: service)
Propagator
- type:
enum - default:
w3c
Context propagator for distributed tracing. Determines how trace context is injected/extracted from HTTP headers.
flow_telemetry:
propagator:
type: w3c # w3c|tracecontext|baggage|service
service_id: null # Custom service ID (only for type: service)
| Type | Description |
|---|---|
w3c |
W3C TraceContext + Baggage (recommended) |
tracecontext |
W3C TraceContext only |
baggage |
W3C Baggage only |
service |
Custom propagator service |
Error Handlers
Per the OpenTelemetry spec, the SDK MUST NOT throw to user code at runtime. Errors raised by exporters/processors are
caught and forwarded to a configured error handler. The bundle exposes a named map under error_handlers: that is
referenced from provider, processor, and OTLP exporter blocks via error_handler: fields.
flow_telemetry:
error_handlers:
default:
type: error_log # default if omitted
to_file:
type: stream
destination: '%kernel.logs_dir%/flow-telemetry-errors.log'
to_syslog:
type: syslog
facility: local0
severity: warning
fanout:
type: composite
handlers: [ default, to_file, to_syslog ]
silent:
type: noop
custom:
type: service
service_id: app.my_error_handler
If error_handlers: is omitted (or default is missing inside it), the bundle injects
error_handlers.default = { type: error_log } automatically so every error_handler: reference always resolves.
Service IDs registered by the bundle (predictable for decorates:):
flow.telemetry.error_handler.<name>— e.g.flow.telemetry.error_handler.default
error_log (default)
Writes formatted Throwables via PHP's error_log() — stderr in CLI by default, or the error_log ini setting
otherwise.
Matches the OTEL spec recommendation to log to standard error output.
| Option | Type | Default | Description |
|---|---|---|---|
message_type |
enum | operating_system |
operating_system (0), email (1), file (3), sapi (4) |
expand_newlines |
boolean | false |
Emit one error_log() call per line of the formatted message |
message_prefix |
string | [flow-telemetry] |
Prefix prepended to every message |
stream
Appends formatted Throwables (one per line) to a file path or php:// stream wrapper. The handle is opened lazily on
the first call and reused.
| Option | Type | Default | Description |
|---|---|---|---|
destination |
string | - | File path or php://stdout/php://stderr/etc. (required) |
file_permissions |
integer | 0644 |
Permissions for newly created files (ignored for php://) |
create_directories |
boolean | true |
Create parent directories of the destination if missing |
message_prefix |
string | [flow-telemetry] |
Prefix prepended to every line |
syslog
Writes via openlog/syslog/closelog.
| Option | Type | Default | Description |
|---|---|---|---|
ident |
string | flow-telemetry |
Syslog identity tag |
facility |
enum | user |
RFC 5424 facility (see table below) |
log_opts |
integer | LOG_PID |
Bitmask of LOG_* flags passed to openlog() |
severity |
enum | error |
RFC 5424 severity (see table below) |
udp_syslog
Sends RFC 5424 syslog frames over UDP.
| Option | Type | Default | Description |
|---|---|---|---|
host |
string | - | Remote syslog host (required) |
port |
integer | 514 |
Remote syslog port |
ident |
string | flow-telemetry |
Syslog identity tag |
facility |
enum | user |
RFC 5424 facility |
severity |
enum | error |
RFC 5424 severity |
composite
Fans an error out to multiple named handlers. Each child invocation is wrapped so a misbehaving handler cannot prevent siblings from running.
| Option | Type | Default | Description |
|---|---|---|---|
handlers |
list of strings | - | Names of other entries in error_handlers: (required) |
fanout:
type: composite
handlers: [ default, to_file ]
noop
Discards every Throwable. Intended for tests or explicit silence.
silent: { type: noop }
service
Aliases an existing service that implements Flow\Telemetry\ErrorHandler\ErrorHandler.
| Option | Type | Default | Description |
|---|---|---|---|
service_id |
string | - | Service id of the user-provided handler (required) |
custom:
type: service
service_id: app.my_error_handler
Facility values
| Value | Constant |
|---|---|
kernel |
LOG_KERN |
user |
LOG_USER |
mail |
LOG_MAIL |
daemon |
LOG_DAEMON |
auth |
LOG_AUTH |
syslog |
LOG_SYSLOG |
lpr |
LOG_LPR |
news |
LOG_NEWS |
uucp |
LOG_UUCP |
cron |
LOG_CRON |
local0..local7 |
LOG_LOCAL0..LOG_LOCAL7 |
Severity values
| Value | Constant |
|---|---|
emergency |
LOG_EMERG |
alert |
LOG_ALERT |
critical |
LOG_CRIT |
error |
LOG_ERR |
warning |
LOG_WARNING |
notice |
LOG_NOTICE |
info |
LOG_INFO |
debug |
LOG_DEBUG |
Exporters (named definitions)
The bundle exposes a top-level named map of exporters. The implementation is selected by the sub-block name
under each entry — there is no separate type: key. Each exporter must declare exactly one of the supported
sub-blocks: otlp, service, console, memory, void. Per-signal processors reference exporters by name.
flow_telemetry:
exporters:
otlp: # exporter name
otlp: # sub-block selects implementation
transport:
type: curl
endpoint: 'http://otel-collector:4318'
encoding: protobuf
| Sub-block | Options | Description |
|---|---|---|
otlp |
transport: { ... } |
Sends batches over OTLP curl/grpc/service |
service |
id: <service_id> |
Aliases an existing user-provided service |
console |
none (use ~ / null / {}) |
Pretty-prints to console |
memory |
none | Stores batches in memory (testing) |
void |
none | Discards everything (no-op) |
Service IDs registered by the bundle (predictable for decorates:):
flow.telemetry.exporter.<name>— e.g.flow.telemetry.exporter.otlpflow.telemetry.exporter.<name>.transport— only when the exporter uses theotlpsub-block
TracerProvider
Configures the tracer provider for distributed tracing.
flow_telemetry:
tracer_provider:
error_handler: default # name from error_handlers; defaults to "default"
sampler:
type: always_on # always_on|always_off|trace_id_ratio|parent_based|attribute_matching|service
ratio: 1.0 # Sampling ratio (0.0-1.0, only for trace_id_ratio)
service_id: null # Custom sampler service (only for type: service)
processor:
type: batching # composite|memory|batching|passthrough|void|attribute_filtering|service
batch_size: 512
exporter: otlp # name of a top-level exporter
error_handler: default
Sampler types:
| Type | Description |
|---|---|
always_on |
Sample all traces (default) |
always_off |
Sample no traces |
trace_id_ratio |
Sample based on trace ID ratio |
parent_based |
Respect parent span's sampling decision |
attribute_matching |
Drop spans matching an attribute matcher (start-time attrs); defer the rest to a delegate sampler |
service |
Custom sampler service |
attribute_matching is the OTel-idiomatic way to drop spans by attribute — the decision is made at span start, so a
matched span never records or reaches a processor. It only sees attributes available at span start (not end-state values
like status codes). It reuses the same matcher / exclude / sources / cache_dir options as the
attribute_filtering processor, plus a delegate sampler for spans that don't match:
flow_telemetry:
tracer_provider:
sampler:
type: attribute_matching
matcher:
any:
- { path: http.route, mode: equal, value: /health }
- { path: http.route, mode: starts_with, value: /ready }
# exclude: true (default) drops matches; sources: [signal] (default)
delegate:
type: trace_id_ratio # always_on (default) | always_off | trace_id_ratio
ratio: 0.1 # everything that is NOT a health/ready probe is 10% sampled
MeterProvider
flow_telemetry:
meter_provider:
error_handler: default
temporality: cumulative # cumulative|delta
processor:
type: batching
batch_size: 512
exporter: otlp
error_handler: default
LoggerProvider
flow_telemetry:
logger_provider:
error_handler: default
processor:
type: batching # composite|memory|batching|passthrough|void|pipeline|service
batch_size: 512
exporter: otlp
error_handler: default
Pipeline (logs only) — chain middleware (enrich/filter) before a terminal sink:
flow_telemetry:
logger_provider:
processor:
type: pipeline
middleware:
- { type: severity_filtering, minimum_severity: warn } # trace|debug|info|warn|error|fatal
sink:
type: batching
exporter: otlp
batch_size: 200
Processor Configuration
Processor types available for tracer_provider, meter_provider, and logger_provider.
void (default)
Discards all data. No additional options.
processor:
type: void
passthrough
Immediately exports each item via the referenced exporter.
processor:
type: passthrough
exporter: console
memory
Stores in memory (for testing). Still requires a backing exporter for flush() to call.
processor:
type: memory
exporter: memory
batching
Batches items before export.
| Option | Type | Default | Description |
|---|---|---|---|
batch_size |
integer | 512 |
Number of items per batch |
exporter |
string | - | Name of a top-level exporter (required) |
processor:
type: batching
batch_size: 512
exporter: otlp
composite
Combines multiple processors. Each child references its own exporter by name.
processor:
type: composite
processors:
- { type: batching, exporter: otlp, batch_size: 512 }
- { type: memory, exporter: memory }
service
Custom processor service.
processor:
type: service
service_id: 'app.custom_processor'
pipeline (logger_provider only)
Runs each log record through an ordered list of middleware (enrich / filter), then forwards the survivors to a
single sink. This is the log-only way to combine more than one processing step (the OpenTelemetry
LogRecordProcessor model: each step may modify the record or drop it, with changes visible to the next).
| Option | Type | Default | Description |
|---|---|---|---|
middleware |
list | [] |
Ordered middleware; the first to drop a record short-circuits the rest |
sink |
object | – (required) | Terminal processor (memory, batching, passthrough, void, service, or composite) that exports survivors |
Each middleware entry has a type and its own options:
type |
Options | Effect |
|---|---|---|
enriching |
attributes (map) |
Merges default attributes (call-site values win) |
severity_filtering |
minimum_severity (trace…fatal, default info) |
Drops records below the level |
attribute_filtering |
matcher, exclude, sources, cache_dir (see attribute_filtering) |
Drops/keeps records by attribute matcher |
processor:
type: pipeline
middleware:
- { type: enriching, attributes: { 'deployment.environment': prod } }
- { type: severity_filtering, minimum_severity: warn }
- type: attribute_filtering
matcher: { any: [ { path: http.route, mode: equal, value: /health } ] }
sink:
type: batching
exporter: otlp
batch_size: 200
attribute_filtering
Drops (or keeps) signals based on their attributes — useful for reducing noise, e.g. discarding health-check or bot
traffic before it is exported. On tracer_provider and meter_provider it is a processor that wraps an
inner_processor (which receives the survivors). On logger_provider it is instead a middleware inside a
pipeline (no inner_processor — the pipeline's sink receives the survivors). The
matcher/exclude/sources/cache_dir options below apply in both forms.
| Option | Type | Default | Description |
|---|---|---|---|
matcher |
object | – (required) | The matcher tree (see below) |
exclude |
boolean | true |
true: drop matching signals; false: keep ONLY matching signals |
sources |
list of enum | [signal] |
Which attribute sets to inspect: any of signal, resource, scope. The matcher is evaluated against each listed source and OR-combined — a signal matches if it matches in any source |
cache_dir |
string | %kernel.cache_dir%/flow_telemetry_filters |
Directory for the compiled matcher cache |
cache_dir_permissions |
int | 0o700 |
Octal mode applied when the cache directory is created (owner-only by default, since it holds required PHP; subject to umask). Write it as a YAML octal literal, e.g. 0o750. Only applies on creation — an existing directory is left untouched |
inner_processor |
object | – (required for span/metric) | tracer/meter only — the wrapped processor that receives surviving signals (leaf types only: memory, batching, passthrough, void, service). For logs, omit it: the pipeline sink receives survivors |
The matcher is a tree. Each node is exactly one of:
all: [ … ]— a list of child matchers; matches when every child matches (AND).any: [ … ]— a list of child matchers; matches when at least one child matches (OR).not: { … }— a single child matcher; matches when the child does not.- a leaf rule carrying a
path(the four kinds are mutually exclusive — mixing them in one node is a configuration error).
Composites and leaves nest to any depth, so arbitrary combinations of ANDs, ORs and NOTs are expressible. A leaf rule accepts:
| Option | Type | Default | Description |
|---|---|---|---|
path |
string|list | required | Attribute key, or a list of segments descending into nested array values |
mode |
enum | required | Comparison mode (see below) |
value |
scalar | required | Expected value (must be a string for the pattern modes) |
case_sensitive |
boolean | true |
Substring modes only |
Modes: equal, not_equal, greater_than, greater_than_equal, less_than, less_than_equal, regexp,
starts_with, ends_with, contains.
Drop health-check and bot logs (drop if any branch matches) — on logger_provider, attribute filtering is a
middleware inside a pipeline; the sink receives the survivors:
flow_telemetry:
logger_provider:
processor:
type: pipeline
middleware:
- type: attribute_filtering
matcher:
any:
- { path: http.route, mode: equal, value: /health }
- { path: http.user_agent, mode: contains, value: bot, case_sensitive: false }
sink:
type: batching
exporter: otlp
Keep only payments-scope metrics that are not internal (exclude: false, sources: [scope], negation):
flow_telemetry:
meter_provider:
processor:
type: attribute_filtering
exclude: false
sources: [ scope ]
matcher:
all:
- { path: name, mode: equal, value: app.payments }
- not: { path: internal, mode: equal, value: true }
inner_processor:
type: batching
exporter: otlp
Inspect more than one source at once — drop a signal whose route is /health whether that attribute sits on the
signal itself or on the resource:
flow_telemetry:
tracer_provider:
processor:
type: attribute_filtering
sources: [ signal, resource ]
matcher: { path: http.route, mode: equal, value: /health }
inner_processor:
type: batching
exporter: otlp
Deeply nested combinations — drop a span when it is a fast internal call or a non-health route on a flagged tenant
((duration < 5 AND internal) OR (tenant = a AND NOT route ^= /health)):
flow_telemetry:
tracer_provider:
processor:
type: attribute_filtering
matcher:
any:
- all:
- { path: [ timing, duration_ms ], mode: less_than, value: 5 }
- { path: internal, mode: equal, value: true }
- all:
- { path: tenant, mode: equal, value: a }
- not: { path: http.route, mode: starts_with, value: /health }
inner_processor:
type: batching
exporter: otlp
Filtering logs by severity (logs only). Severity is not an attribute, but on logger_provider the
attribute_filtering middleware exposes the record's severity to the matcher as two reserved signal keys, so
per-channel (or per-attribute) severity thresholds are expressible alongside any other attribute:
log.severity— the OpenTelemetry severity number (trace=1,debug=5,info=9,warn=13,error=17,fatal=21), for ordered comparisons (greater_than_equal, …).log.severity_name— the level name (TRACE…FATAL), forequal/regexp.
These keys exist only for the filter decision — they are never exported (severity already travels in the native OTLP
severityNumber/severityText fields) and they shadow any user attribute of the same name. Because they live on the
signal source, an all node that combines severity with another attribute needs that attribute on signal too — the
channel's log.channel attribute is on signal under the default channel_attribute_target: both.
Keep error+ from the payments channel but debug+ from the importer channel (exclude: false keeps only
matching records — exactly the case a single global severity_filtering threshold
cannot express):
flow_telemetry:
logger_provider:
processor:
type: pipeline
middleware:
- type: attribute_filtering
exclude: false
matcher:
any:
- all:
- { path: log.channel, mode: equal, value: payments }
- { path: log.severity, mode: greater_than_equal, value: 17 } # error+
- all:
- { path: log.channel, mode: equal, value: importer }
- { path: log.severity, mode: greater_than_equal, value: 5 } # debug+
sink:
type: batching
exporter: otlp
For a single global threshold prefer the simpler severity_filtering middleware; reach for log.severity only when
the threshold varies by channel or another attribute.
The matcher is compiled to a cached PHP matcher in cache_dir (the project cache directory by default) so per-signal
evaluation stays cheap; it falls back to interpreted matching when the directory is not writable. Because the compiled
file is required, cache_dir must be trusted (not writable by untrusted users) — the project cache directory is
application-private, so the default is safe.
Exporter Definitions
Exporters are declared once at the top level under exporters: and referenced from processor blocks by name. Each
exporter declares exactly one sub-block; the sub-block name selects the implementation.
void
Discards all data.
exporters:
drop: { void: ~ }
memory
In-memory store for testing. Service exposes allLogs(), allMetrics(), allSpans() accessors via
Flow\Telemetry\Provider\Memory\MemoryExporter.
exporters:
capture: { memory: ~ }
console
Pretty-prints logs, metrics, and spans to the console. Useful for development.
exporters:
debug: { console: ~ }
otlp
Sends batches over an embedded transport. The single OTLP exporter handles all three signals.
exporters:
otlp:
otlp:
error_handler: default # name from error_handlers; defaults to "default"
transport:
type: curl
endpoint: 'http://otel-collector:4318'
encoding: protobuf
service
Aliases an existing user-defined service implementing Flow\Telemetry\Exporter\Exporter. This is the escape hatch for
APM-specific exporters (Datadog, New Relic, custom) that don't go through OTLP — define your exporter as a Symfony
service in your own services.yaml and reference it by id.
exporters:
datadog:
service:
id: 'app.datadog_telemetry_exporter'
OTLP Transport Configuration
Inside exporters.<name>.otlp.transport. Required for the otlp sub-block.
Timeouts
Both curl and gRPC default to aggressive, local-collector-friendly per-request timeouts with a separate, looser budget for graceful drain at shutdown:
| Setting | Default | Applies to | Bounds |
|---|---|---|---|
timeout_ms |
250 | curl, grpc | Per-request deadline (curl: total request; grpc: per-call) |
connect_timeout_ms |
250 | curl only | TCP/TLS connect; gRPC has no separate bound |
shutdown_timeout_ms |
5000 | curl, grpc | Wall-clock budget for draining pending requests at shutdown |
The defaults assume the recommended deployment: an OpenTelemetry Collector running close to the application (loopback,
UDS, or sidecar). shutdown_timeout_ms is independent of timeout_ms — keep the per-request value tight to surface
collector slowness during steady-state, while still giving graceful exit a longer window to drain. For a remote
collector across regions, raise both values. See the
OTLP bridge Timeouts section for the rationale.
Failover Transport
Both curl and grpc transports accept an optional nested failover: block. When primary delivery fails, the prior
batch is forwarded to the failover transport and a FailoverTransportException is raised so the operator is informed
even when failover absorbs the data. Common pattern: gRPC primary → stream (JSONL on disk) failover so a downed
collector still leaves recoverable data the operator can replay later.
exporters:
otlp:
otlp:
transport:
type: grpc
endpoint: 'otel-collector:4317'
timeout_ms: 250
failover:
type: stream
endpoint: '%kernel.logs_dir%/otel-failed.jsonl'
Constraints
- The
failover:block accepts the same fields as the parent transport, except it cannot itself declare a nestedfailover:(single-level depth). - Allowed only on
curlandgrpcprimaries.failoverunder astreamorserviceprimary is rejected at config-validation time. - The bundle registers
flow.telemetry.exporter.<name>.failover.transportfor the failover service id.
For the underlying behavior — when a forwarded batch is treated as absorbed vs. lost, the shape of
FailoverTransportException, and the cascade-shutdown contract — see the
OTLP bridge Failover Transport section.
curl (default)
| Option | Type | Default | Description |
|---|---|---|---|
endpoint |
string | - | OTLP base URL (required) |
timeout_ms |
integer | 250 |
Total per-request deadline in milliseconds |
connect_timeout_ms |
integer | 250 |
TCP/TLS connect deadline in milliseconds |
shutdown_timeout_ms |
integer | 5000 |
Wall-clock budget in milliseconds for draining pending requests at shutdown |
compression |
boolean | false |
Enable compression |
follow_redirects |
boolean | true |
Follow HTTP redirects |
max_redirects |
integer | 3 |
Maximum redirects to follow |
proxy |
string | null |
Proxy URL |
ssl_verify_peer |
boolean | true |
Verify SSL peer |
ssl_verify_host |
boolean | true |
Verify SSL host |
ssl_cert_path |
string | null |
SSL certificate path |
ssl_key_path |
string | null |
SSL key path |
ca_info_path |
string | null |
CA info path |
headers |
object | {} |
Additional HTTP headers |
encoding |
enum | json |
OTLP/HTTP wire encoding: json or protobuf |
failover |
object | null |
Optional failover transport |
See Timeouts for guidance on the millisecond defaults.
exporters:
otlp:
otlp:
transport:
type: curl
endpoint: 'http://otel-collector:4318'
timeout_ms: 250
connect_timeout_ms: 250
compression: true
headers:
Authorization: 'Bearer token'
encoding: protobuf
grpc
gRPC transport. encoding is rejected by validation (OTLP/gRPC mandates Protobuf, built internally), and
connect_timeout_ms is rejected because gRPC has no separate connect bound — timeout_ms is the per-call deadline
covering DNS, connect, send and receive together.
| Option | Type | Default | Description |
|---|---|---|---|
endpoint |
string | - | gRPC endpoint (required) |
timeout_ms |
integer | 250 |
Per-call deadline in milliseconds |
shutdown_timeout_ms |
integer | 5000 |
Wall-clock budget in milliseconds for draining pending calls at shutdown |
insecure |
boolean | true |
Allow insecure connections |
headers |
object | {} |
gRPC metadata |
failover |
object | null |
Optional failover transport |
exporters:
otlp_grpc:
otlp:
transport:
type: grpc
endpoint: 'otel-collector:4317'
timeout_ms: 250
insecure: false
stream
OTLP File Exporter (spec). Writes one JSON Line
per batch to the configured destination — either an absolute file path or a php:// stream wrapper — with
LOCK_EX around each fwrite. Only JSON encoding is supported per the spec; encoding and HTTP-specific options
(timeout, ssl_*, headers, etc.) are rejected at config time.
| Option | Type | Default | Description |
|---|---|---|---|
endpoint |
string | - | File path or php:// stream wrapper URI (required) |
file_permissions |
integer | 0644 |
File mode applied when creating new files; ignored for php:// destinations |
create_directories |
boolean | true |
Create the destination's parent directories if missing; ignored for php:// destinations |
exporters:
otlp_logs_file:
otlp:
transport:
type: stream
endpoint: '%kernel.project_dir%/var/otel/logs.jsonl'
file_permissions: 0640
create_directories: true
otlp_logs_stdout:
otlp:
transport:
type: stream
endpoint: 'php://stdout'
To write all three signal types to one file, point three exporters at the same destination — the OpenTelemetry
Collector's otlpjsonfile receiver handles mixed resourceLogs / resourceMetrics / resourceSpans lines.
service
Aliases an existing transport service ID inside the OTLP exporter.
exporters:
otlp:
otlp:
transport:
type: service
service_id: 'app.custom_transport'
Multiple OTLP backends
Each signal can target its own collector by declaring multiple named exporters and referencing them per provider.
flow_telemetry:
exporters:
otlp_traces:
otlp:
transport:
type: grpc
endpoint: 'http://traces:4317'
insecure: false
otlp_metrics:
otlp:
transport:
type: curl
endpoint: 'http://metrics:4318'
encoding: protobuf
otlp_logs:
otlp:
transport:
type: curl
endpoint: 'http://logs:4318'
encoding: json
tracer_provider:
processor: { type: batching, exporter: otlp_traces, batch_size: 1024 }
meter_provider:
processor: { type: batching, exporter: otlp_metrics, batch_size: 256 }
logger_provider:
processor: { type: batching, exporter: otlp_logs, batch_size: 100 }
Migrating from older config
The schema replaced the type: <name> discriminator with a sub-block whose key matches the implementation. There is no
BC shim.
Before (legacy schema)
flow_telemetry:
exporters:
otlp:
type: otlp
transport:
type: curl
endpoint: 'http://otel-collector:4318'
encoding: protobuf
custom:
type: service
service_id: 'app.x'
debug: { type: console }
After
flow_telemetry:
exporters:
otlp:
otlp:
transport:
type: curl
endpoint: 'http://otel-collector:4318'
encoding: protobuf
custom:
service:
id: 'app.x'
debug: { console: ~ }
tracer_provider:
processor: { type: batching, exporter: otlp }
Instrumentation
Configure automatic instrumentation for various Symfony components.
All instrumentation is disabled by default. You must explicitly set enabled: true for each component you want to
instrument.
HTTP Kernel
Traces HTTP requests and responses.
flow_telemetry:
instrumentation:
http_kernel:
enabled: true
context_propagation: true # Extract context from incoming headers
exclude_paths:
- path: '/_profiler'
- path: '/_wdt'
- path: '/health'
method: GET
- path: '/^\/api\/internal\/.*/' # Regex pattern
Console
Traces console commands.
flow_telemetry:
instrumentation:
console:
enabled: true
exclude_commands:
- 'cache:clear'
- 'assets:install'
- '/^debug:.*/' # Regex: exclude all debug commands
Messenger
Traces Symfony Messenger messages with context propagation across message boundaries.
flow_telemetry:
instrumentation:
messenger:
enabled: true
context_propagation: true # Propagate context across message boundaries
Twig
Traces Twig template rendering.
flow_telemetry:
instrumentation:
twig:
enabled: true
trace_templates: true # Trace template rendering
trace_blocks: false # Trace block rendering
trace_macros: false # Trace macro execution
exclude_templates:
- '@WebProfiler'
- '/^@Debug\/.*/'
HTTP Client
Traces Symfony HTTP Client requests.
flow_telemetry:
instrumentation:
http_client:
enabled: true
exclude_clients:
- 'internal.client'
- '/^debug\..*/'
PSR-18 Client
Traces PSR-18 HTTP client requests.
flow_telemetry:
instrumentation:
psr18_client:
enabled: true
exclude_clients:
- 'app.internal_client'
Doctrine DBAL
Traces database queries.
flow_telemetry:
instrumentation:
dbal:
enabled: true
log_sql: true # Include SQL in span attributes
max_sql_length: 1000 # Max SQL length (0 = no limit)
exclude_connections:
- 'legacy'
- '/^test_.*/'
Cache
Traces Symfony Cache operations.
flow_telemetry:
instrumentation:
cache:
enabled: true
exclude_pools:
- 'cache.system'
- '/^cache\.validator.*/'
Web Profiler
Adds a Flow Telemetry panel to the Symfony Web Profiler showing the signals captured during the
current request — spans as a timeline waterfall, metrics, and (when capture_logs is enabled) logs —
so telemetry is visible locally without an external OTLP backend. The toolbar shows the total signal
count; the panel breaks it down per signal type.
flow_telemetry:
profiler:
# enabled: null (default) auto-enables when WebProfilerBundle is registered; true forces it on
# (throws if WebProfilerBundle is absent); false forces it off.
enabled: ~
capture_logs: false # also capture logs and render them in the panel's Logs section (off by default)
When enabled, the bundle mirrors every exported traces/metrics batch into a shared in-memory store by
decorating the exporters the tracer_provider / meter_provider (and, with capture_logs: true, the
logger_provider) reference. OTLP export is unaffected — the real exporter still receives every batch.
The store is exposed under the stable, public service id flow.telemetry.profiler.store.
Named Instruments
Configure named tracers, meters, and loggers with custom attributes.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
version |
string | 'unknown' |
Instrumentation scope version |
schema_url |
string | null |
Schema URL for semantic conventions |
attributes.scope |
object | {} |
Attributes attached to the instrumentation scope |
attributes.signal |
object | {} |
Default attributes merged into every emitted signal |
attributes has two sub-keys:
scope— attributes attached to the instrumentation scope itself (shared by every signal emitted through this tracer/meter/logger). These are what asources: [scope]filter inspects.signal— default attributes merged into every individual signal (each span/metric data point/log record) emitted through this instrument. Attributes passed at the call site (e.g.$logger->info('...', ['env' => 'dev'])) override the configured defaults of the same key. These are what asources: [signal]filter inspects (the default).
flow_telemetry:
tracers:
my_tracer:
version: '1.0.0' # default: 'unknown'
schema_url: 'https://opentelemetry.io/schemas/1.21.0'
attributes:
scope:
custom.attribute: 'value'
signal:
deployment.environment: 'prod'
meters:
my_meter:
version: '1.0.0' # default: 'unknown'
schema_url: null
attributes:
signal:
deployment.environment: 'prod'
loggers:
my_logger:
version: '1.0.0' # default: 'unknown'
Main Logger
The bundle depends on PSR-3 Telemetry Bridge and registers
a PSR-3 wrapper service for every named Telemetry logger at flow.telemetry.<name>.logger.psr3. This makes Flow
Telemetry loggers usable as Symfony's logger service, removing the need for Monolog when telemetry is the only logging
destination.
In addition, the bundle always registers a default logger, meter, and tracer — flow.telemetry.default.logger,
flow.telemetry.default.logger.psr3, flow.telemetry.default.meter, flow.telemetry.default.tracer — regardless of
what is configured under loggers/meters/tracers. Defining your own default entry under those keys is allowed and
will override the auto-default.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
framework_logger |
string | null |
null |
Name of a logger configured under loggers (or the always-available default) to alias as Symfony logger |
Behavior:
- When
framework_loggeris set, the bundle aliases the Symfonyloggerservice toflow.telemetry.<framework_logger>.logger.psr3. If no logger with that name exists, container compilation fails with a clear error. - When
framework_loggerisnulland Symfony'sloggerservice is the defaultSymfony\Component\HttpKernel\Log\Logger, the bundle automatically aliasesloggertoflow.telemetry.default.logger.psr3. - When
framework_loggerisnullandloggeris provided by another bundle (Monolog, custom alias, etc.), the bundle leavesloggeralone.
flow_telemetry:
loggers:
app:
version: '1.0.0'
framework_logger: app # Symfony "logger" service -> flow.telemetry.app.logger.psr3
Logging Channels
Channels let you route different parts of your application to different telemetry loggers — the Flow Telemetry
equivalent of Monolog channels — without installing Monolog. Each channel is a named logger;
messages emitted through it carry a log.channel attribute, so you can filter and group them per channel in your
backend. Where that attribute is placed — the instrumentation scope, every emitted record, or both — is controlled by
channel_attribute_target.
A service opts into a channel by carrying the flow.telemetry.channel tag. The recommended way is the
#[WithTelemetryChannel] attribute:
<?php
namespace App\Messaging;
use Flow\Bridge\Symfony\TelemetryBundle\Attribute\WithTelemetryChannel;
use Psr\Log\LoggerInterface;
#[WithTelemetryChannel('events')]
final class OrderSubscriber
{
public function __construct(
private readonly LoggerInterface $logger, // resolves to the "events" channel logger
) {
}
}
The autowired LoggerInterface now resolves to flow.telemetry.events.logger.psr3 instead of the default logger. A
tagged service may instead typehint the native Flow\Telemetry\Logger\Logger — it resolves to that channel's native
logger (flow.telemetry.events.logger), the instance the PSR-3 wrapper delegates to. Both the PSR-3 interface and the
native class are bound, so either typehint works.
The channel can also be requested with a raw tag in services.yaml:
services:
App\Messaging\OrderSubscriber:
tags:
- { name: 'flow.telemetry.channel', channel: 'events' }
Behavior:
- Each distinct channel is synthesized on demand as
flow.telemetry.<channel>.logger(+ its PSR-3 wrapperflow.telemetry.<channel>.logger.psr3), carrying alog.channel: <channel>attribute placed perchannel_attribute_target— unless a logger of that name already exists, which is then reused untouched (so thelog.channelattribute is only added to loggers the bundle creates). - To route a service to the bundle's main logger, use the
defaultchannel (#[WithTelemetryChannel('default')]). Adefaultlogger always exists, so it is reused as-is rather than re-created. Every channel name — includingapp, which carries no special meaning here — behaves the same way. - For every channel in use, two named-argument autowiring aliases are registered —
LoggerInterface $<channel>Logger(the PSR-3 wrapper) andLogger $<channel>Logger(the native FlowLogger) — so any service can request a channel logger by argument name (e.g.LoggerInterface $eventsLogger,Logger $httpClientLogger) without the tag. - On a tagged service, an explicit
@loggerreference is rewritten to the channel logger as well — in both constructor arguments and method calls (e.g.setLogger()), preserving the reference's invalid-behavior flag. - A channel already declared under
loggersis not overwritten, so you can customise itsversion,schema_url, orattributes. Because declaring it opts out of synthesis, setlog.channelyourself if you want it:
flow_telemetry:
loggers:
events:
version: '1.0.0'
attributes:
scope:
log.channel: events # not auto-added for a declared logger — set it explicitly
team: checkout
Capturing Framework Channels
Symfony's own services already declare channels by tagging themselves monolog.logger (the router, the request logger,
the event dispatcher, the HTTP client, cache pools, the messenger, …) — that tag comes from FrameworkBundle and is
present whether or not MonologBundle is installed. Enable capture_framework_channels and the bundle consumes that
tag, routing these framework channels to Flow telemetry loggers exactly the way MonologBundle would — making it a
drop-in replacement for Monolog's channel routing, without Monolog.
It is disabled by default, because MonologBundle claims the same monolog.logger tag: if both are installed and
both process it, they fight over the same services. Enable it only when Flow telemetry owns channel routing (i.e. you
are not running MonologBundle):
flow_telemetry:
capture_framework_channels: true # default: false
Each framework channel becomes flow.telemetry.<channel>.logger (e.g. flow.telemetry.router.logger,
flow.telemetry.http_client.logger), carries the log.channel attribute (placed per
channel_attribute_target), and gets the
LoggerInterface $<channel>Logger / Logger $<channel>Logger autowiring aliases — the same treatment as an explicitly
tagged service. A channel you declare under loggers still wins, so you can customise any framework channel's scope.
Services that opt in explicitly via #[WithTelemetryChannel] / the flow.telemetry.channel tag are always routed
regardless of this flag.
Channel Attribute Placement
The synthesized log.channel attribute can be attached to the instrumentation scope, to every emitted signal
(log record), or to both. channel_attribute_target controls this for all synthesized channel loggers —
framework-captured and #[WithTelemetryChannel] alike:
flow_telemetry:
channel_attribute_target: both # both (default) | scope | signal
scope—log.channelsits on the instrumentation scope. Filter by channel with anattribute_filteringprocessor usingsources: [scope]. Not present on individual records.signal—log.channelis merged into every emitted record, so it is visible per-record and filterable with the defaultsources: [signal](the closest match to how Monolog stamps the channel on each record).both(default) — placed on the scope and every record, so it is filterable either way at the cost of storing the attribute on each record.
This only governs channels the bundle synthesizes; a channel you declare yourself under loggers opts out of synthesis,
so set log.channel explicitly on whichever of attributes.scope / attributes.signal you want.
[!NOTE]
capture_framework_channelsrewrites theloggerreference on framework services to their channel logger. A service-specific channel takes precedence over the globalframework_loggerredirect.
Coexisting with MonologBundle
You can run this bundle alongside MonologBundle — as long as capture_framework_channels stays false (the
default). The two are independent because they read different DI tags: framework capture reads Symfony's
monolog.logger, while the #[WithTelemetryChannel] attribute uses this bundle's own flow.telemetry.channel tag,
which Monolog never looks at. With the flag off, this bundle never touches monolog.logger, so:
- MonologBundle keeps full ownership of every framework channel (router, request, Doctrine, …) and the default
applogger — uncontested. #[WithTelemetryChannel('events')]still scopes an individual class: it binds only that service'sLoggerInterface(and nativeLogger) to the Flow telemetry channel logger. Because the binding fills the argument directly, it wins over Monolog's globalLoggerInterfaceautowiring alias for that one class; every other class keeps resolving to Monolog.
What you cannot do is enable capture_framework_channels and keep MonologBundle: both passes then rewrite the
same
monolog.logger-tagged services, and the outcome depends on compiler-pass ordering. Ownership of the framework channels
is all-or-nothing:
- Flow telemetry owns the framework channels —
capture_framework_channels: true, MonologBundle not installed. - Monolog owns the framework channels, Flow scopes specific classes —
capture_framework_channels: false, MonologBundle installed.
The #[WithTelemetryChannel] attribute works in both setups.
Pattern Matching
Several configuration options support pattern matching for exclusion lists (paths, commands, templates, etc.).
Exact String Matching
Patterns without regex delimiters match exactly:
exclude_paths:
- path: '/_profiler' # Matches exactly /_profiler
- path: '/health' # Matches exactly /health
Regex Matching
Patterns enclosed in / delimiters are treated as regular expressions:
exclude_paths:
- path: '/^\/api\/internal\/.*/' # Regex: matches /api/internal/*
exclude_commands:
- '/^debug:.*/' # Regex: matches debug:* commands
exclude_templates:
- '/^@Debug\/.*/' # Regex: matches @Debug/* templates
Usage
Accessing Telemetry in Services
Inject the Telemetry service to create custom spans, metrics, and logs:
<?php
namespace App\Service;
use Flow\Telemetry\Telemetry;
final class OrderService
{
public function __construct(
private readonly Telemetry $telemetry,
) {
}
public function processOrder(int $orderId): void
{
$tracer = $this->telemetry->tracer('order-service');
$tracer->trace('process_order', function () use ($orderId) {
// Your order processing logic
}, [
'order.id' => $orderId,
]);
}
}
Creating Custom Spans in Controllers
<?php
namespace App\Controller;
use Flow\Telemetry\Telemetry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class CheckoutController extends AbstractController
{
public function __construct(
private readonly Telemetry $telemetry,
) {
}
#[Route('/checkout', name: 'checkout')]
public function checkout(): Response
{
$tracer = $this->telemetry->tracer('checkout');
return $tracer->trace('checkout_page', function () {
// Nested span for cart validation
return $this->telemetry->tracer('checkout')->trace(
'validate_cart',
fn () => $this->render('checkout/index.html.twig')
);
});
}
}
Recording Metrics
<?php
$meter = $this->telemetry->meter('business-metrics');
// Counter
$meter->counter('orders_processed')
->add(1, ['status' => 'completed']);
// Histogram
$meter->histogram('order_value')
->record(99.99, ['currency' => 'USD']);
// Gauge
$meter->gauge('active_users')
->record(42);
Logging with Telemetry
<?php
$logger = $this->telemetry->logger('app');
$logger->info('Order processed', [
'order_id' => 12345,
'amount' => 99.99,
]);
Complete Production Example
# config/packages/flow_telemetry.yaml
flow_telemetry:
resource:
custom:
service.name: 'my-app'
service.version: '%env(APP_VERSION)%'
propagator:
type: w3c
exporters:
otlp_traces:
otlp:
transport:
type: curl
endpoint: '%env(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT)%'
timeout_ms: 250
connect_timeout_ms: 250
headers:
Authorization: 'Bearer %env(OTEL_AUTH_TOKEN)%'
encoding: protobuf
failover:
type: stream
endpoint: '%kernel.logs_dir%/otel-traces-failed.jsonl'
otlp_metrics:
otlp:
transport:
type: curl
endpoint: '%env(OTEL_EXPORTER_OTLP_METRICS_ENDPOINT)%'
encoding: protobuf
otlp_logs:
otlp:
transport:
type: curl
endpoint: '%env(OTEL_EXPORTER_OTLP_LOGS_ENDPOINT)%'
encoding: protobuf
tracer_provider:
sampler:
type: trace_id_ratio
ratio: 0.1 # Sample 10% of traces in production
processor:
type: batching
batch_size: 512
exporter: otlp_traces
meter_provider:
temporality: cumulative
processor:
type: batching
exporter: otlp_metrics
logger_provider:
processor:
type: pipeline
middleware:
- { type: severity_filtering, minimum_severity: info }
sink:
type: batching
exporter: otlp_logs
loggers:
app:
version: '%env(APP_VERSION)%'
audit:
version: '%env(APP_VERSION)%'
attributes:
scope:
channel: audit
meters:
business:
version: '%env(APP_VERSION)%'
tracers:
checkout:
version: '%env(APP_VERSION)%'
framework_logger: app # Symfony "logger" service -> flow.telemetry.app.logger.psr3
instrumentation:
http_kernel:
enabled: true
exclude_paths:
- path: '/_profiler'
- path: '/_wdt'
- path: '/health'
method: GET
console:
enabled: true
exclude_commands:
- 'cache:clear'
- 'cache:warmup'
- '/^debug:.*/'
messenger:
enabled: true
context_propagation: true
dbal:
enabled: true
log_sql: true
max_sql_length: 500
cache:
enabled: true
exclude_pools:
- 'cache.system'
Found a typo or an outdated section? Edit this page on GitHub