nmspaced / telemetry-weaver
OpenTelemetry traces, metrics and logs for Symfony applications running in worker mode (FrankenPHP, RoadRunner).
Package info
github.com/nmspaced/telemetry-weaver
Type:symfony-bundle
pkg:composer/nmspaced/telemetry-weaver
Requires
- php: ^8.4
- open-telemetry/api: ^1.10
- open-telemetry/context: ^1.5
- open-telemetry/sdk: ^1.15
- open-telemetry/sem-conv: ^1.44
- psr/http-client-implementation: ^1.0
- psr/http-factory-implementation: ^1.0
- psr/log: ^3.0
- symfony/config: ^8.1
- symfony/dependency-injection: ^8.1
- symfony/http-client-contracts: ^3.7
- symfony/http-foundation: ^8.1
- symfony/http-kernel: ^8.1
Requires (Dev)
- carthage-software/mago: ^1.49
- doctrine/dbal: ^4
- ergebnis/composer-normalize: ^2.53
- friendsofphp/php-cs-fixer: ^3.95
- igor-php/igor-php: ^0.9.6
- monolog/monolog: ^3.5
- nyholm/psr7: ^1.8
- open-telemetry/exporter-otlp: ^1.4
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.3
- rector/rector: ^2.6
- symfony/amazon-sqs-messenger: ^8.1
- symfony/amqp-messenger: ^8.1
- symfony/cache: ^8.1
- symfony/clock: ^8.1
- symfony/console: ^8.1
- symfony/event-dispatcher: ^8.1
- symfony/framework-bundle: ^8.1
- symfony/http-client: ^8.1
- symfony/mailer: ^8.1
- symfony/messenger: ^8.1
- symfony/monolog-bundle: ^4.0
- symfony/routing: ^8.1
- symfony/scheduler: ^8.1
- symfony/security-core: ^8.1
- symfony/serializer: ^8.1
- symfony/yaml: ^8.1
Suggests
- doctrine/dbal: Enables tracing and metrics for database queries
- open-telemetry/exporter-otlp: Export traces and metrics over OTLP
- open-telemetry/gen-otlp-protobuf: Protobuf encoding for the OTLP exporter
- symfony/cache: Enables tracing and metrics with TraceableCachePool
- symfony/console: Enables tracing for console commands
- symfony/http-client: Enables tracing and metrics for outgoing HTTP requests
- symfony/mailer: Enables tracing for outgoing mail transports
- symfony/messenger: Enables tracing for message consumption and dispatch
- symfony/routing: Adds http.route and the route template to server span names
- symfony/scheduler: Enables scheduler-aware attributes on scheduled message spans
- symfony/security-core: Adds the authenticated user to server spans (opt-in through http_server.record_user_id / record_user_roles)
Provides
None
Conflicts
None
Replaces
None
README
OpenTelemetry for Symfony applications whose PHP process outlives the request.
English · Русский
PHP 8.4+ · Symfony 8.1+ · OpenTelemetry PHP SDK · MIT
Telemetry Weaver wires traces, metrics and logs into Symfony and takes responsibility for the two things that are hard when the process does not end with the request: keeping one unit of work's context out of the next one, and keeping telemetry from spending the application's time. Sampling, propagation, aggregation and OTLP stay with the official SDK.
What it does well
- Worker-mode context containment. A request, a message and a command are explicit
execution boundaries. Spans, context activations and measurements that Weaver created are
closed there; unfinished work is abandoned rather than inherited by the next request. This
is the whole design, not a feature flag — FrankenPHP, RoadRunner and
messenger:consumeare the primary target, request-per-process is the easy case. - Failures stay inside telemetry. An instrumentation that throws does not replace your return value, and never replaces the original business exception. Everything the bundle fails at is rate-limited into one Monolog channel, together with the SDK's own diagnostics.
- Export cannot run away with a request. One deadline covers the whole boundary, whatever it has to send. Collectors that time out are skipped for a cooldown; a hung collector does not consume the time a healthy one needs. Synchronous retries are off by default because in PHP they are sleeps in the worker.
- A public API with no OpenTelemetry types in it.
Telemetry,Operation,Span,Metrics— enough to trace and measure a business operation without learning Context, scopes or exporters. The full OpenTelemetry metrics API is one call away when you want it. - Instrumentation that knows Symfony. HTTP server and client, Doctrine, Messenger, Console, Cache, Serializer, Mailer, Scheduler, Monolog and the PHP runtime — wired by compiler passes that only activate when the component is actually installed.
- Conservative by default. SQL text, client IPs, user identifiers and mail subjects are opt-in, one key each. Span attributes are never copied into metric labels, so turning one on changes what a trace carries, never how many time series exist.
- Replaceable where it matters. Sampler, id generator, span processors, metric views, exporters, transports and whole providers can be replaced through Symfony DI. Service ids are validated when the container compiles, not on the first request.
Installation
composer require nmspaced/telemetry-weaver open-telemetry/exporter-otlp symfony/http-client nyholm/psr7
// config/bundles.php return [ // ... Nmspaced\TelemetryWeaver\TelemetryWeaverBundle::class => ['all' => true], ];
Optional instrumentation activates when its component is installed: Doctrine needs DBAL 4,
OTLP log export needs Monolog and MonologBundle, user.roles on the server span needs
symfony/security-core. For OTLP over gRPC add open-telemetry/transport-grpc and ext-grpc.
Getting started
The SDK is configured by environment, the bundle by YAML. A working minimum:
OTEL_SERVICE_NAME=orders-api OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 # Weaver builds the providers; the SDK must not build a second set. OTEL_PHP_AUTOLOAD_ENABLED=false
# config/packages/open_telemetry.yaml open_telemetry: enabled: true
That is a complete configuration. Every component is instrumented, every sensitive capture is off, and the export budget defaults to one second per boundary with no retries.
Send an OTLP endpoint a real trace in ten minutes: Getting started. Before production, read Configuration — sampling, the flush budget, FPM request metrics and worker identity are the four decisions worth making deliberately.
Built-in instrumentation
| Component | Spans | Metrics |
|---|---|---|
| HTTP server | {method} {route}, server kind, route template, status |
http.server.request.duration, request/response body size |
| HTTP client | {method}, context propagated to the callee |
http.client.request.duration, request/response body size |
| Doctrine DBAL | statement spans with a query summary, transaction boundaries | db.client.operation.duration |
| Messenger | dispatch, send and process spans; context travels on the message | sent, consumed, messaging.process.duration |
| Console | one span per command, exit code and errors | console.command.duration, off by default |
| Cache | one span per pool operation | cache.operation.duration, cache.lookup.count with hit/miss |
| Serializer | serialize/deserialize spans inside an existing trace | serializer.operation.duration, off by default |
| Mailer | one span per transport send | mailer.send.duration, off by default |
| Scheduler | scheduled task spans inside message processing | scheduler.task.duration, off by default |
| Monolog | trace_id/span_id on every record, optional OTLP log export |
— |
| PHP runtime | — | php.memory.usage, php.worker.uptime per worker |
Telemetry follows semantic conventions 1.44.0. Per-component keys, defaults and the reasoning
behind each: Instrumentation.
Tracing your own code
Telemetry is autowired.
use Nmspaced\TelemetryWeaver\Api\Span; use Nmspaced\TelemetryWeaver\Api\Telemetry; final readonly class OrderWorkflow { public function __construct(private Telemetry $telemetry) {} public function place(string $orderId, \Closure $work): mixed { return $this->telemetry->trace('order.place', function (Span $span) use ($orderId, $work): mixed { $span->attribute('app.order.id', $orderId); return $work(); }); } }
The callback runs once. The span closes on the way out, exceptions included, and a telemetry failure never becomes your application's failure.
To trace and measure the same operation, describe it once. Create the instrument once — instruments are keyed by name — and hand it to every operation it measures:
$this->duration = $telemetry->metrics()->duration( 'app.payment.duration', unit: DurationUnit::Seconds, boundaries: [0.01, 0.05, 0.1, 0.5, 1, 5], ); $accepted = $telemetry ->operation('payment.charge') ->duration($this->duration, attributes: ['app.payment.method' => 'card']) ->run(function (OperationContext $context) use ($charge): bool { $accepted = $charge(); if (!$accepted) { $context->fail('payment.declined'); } return $accepted; });
The operation owns the clock, so the measurement is correlated with its own span and cannot be
started at the wrong moment. fail() marks the span and the histogram at once, without
inventing an exception. Metric attributes stay separate from span attributes on purpose: an
order id belongs in a trace, and in a metric label it is a new time series.
Also in the API: every OpenTelemetry instrument through metrics(), Operation::baggage() for
values that must travel to the services you call, and Span::traceId() for putting a trace id
on an error page. See Application API.
Worker lifecycle
Weaver distinguishes the PHP process, the Symfony container, one unit of work and one operation — and only conflates them where the runtime really does:
FPM / request per process Shared worker (FrankenPHP, RoadRunner, Messenger)
─────────────────────────── ────────────────────────────────────────────────
request worker starts
└─ work ├─ request A ─── flush
└─ terminate ─── shutdown ├─ request B ─── flush
└─ worker stops ─ shutdown
A flush drains a pipeline that keeps living; a shutdown ends it. Which one a boundary performs
follows Symfony's runtime mode, not PHP_SAPI. Everything Weaver owns for that unit of work is
released at the boundary either way. See Architecture and lifecycle.
Testing
InMemoryTelemetry is the public test double — no container and no collector:
use Nmspaced\TelemetryWeaver\Testing\InMemoryTelemetry; $telemetry = InMemoryTelemetry::create(); try { (new OrderWorkflow($telemetry))->place('order-42', static fn(): bool => true); self::assertSame('order.place', $telemetry->spans()[0]->getName()); self::assertNull($telemetry->activeTrace()); // the operation left nothing active } finally { $telemetry->shutdown(); }
Documentation
- Getting started — first trace, first metric, the application API
- Configuration — environment, budget, sampling, FPM, worker identity
- Instrumentation — per component: what it emits and what it costs
- Architecture and lifecycle — ownership, boundaries, layers
- Export resilience — the flush budget and what it protects
- SDK customization — replacing parts of the pipeline
config/minimal_config.yaml·config/example_config.yaml— every key with its default
Licensed under MIT.