jcchavezs/zipkin-instrumentation-symfony
A Symfony bundle for Zipkin distributed tracing that instruments HTTP requests and application spans, helping you collect and report timing data to a Zipkin server for performance monitoring and debugging across services.
1. Verify installability before you write any code. Release 3.1.0 raises the tested matrix to PHP 7.4–8.1 on Symfony 4.4/5.x, so PHP 8.1 is now a supported target. Start with a dependency check, not a require:
composer require jcchavezs/zipkin-instrumentation-symfony --dry-run
Expect it to resolve cleanly on Symfony 4.4/5.x and PHP 7.4–8.1. On Symfony 6/7 or PHP 8.2+ you will still hit constraint conflicts — 3.1.0 added no new Symfony support, only PHP. Decide up front whether you are forking the bundle or pinning an older Symfony. Don't discover this in a sprint.
2. Register the bundle. This is the Symfony equivalent of adding a service provider to config/app.php. In config/bundles.php:
return [
// ...
Zipkin\Instrumentation\Symfony\Bundle\ZipkinBundle::class => ['all' => true],
];
3. Point it at a collector. Run one locally first:
docker run -d -p 9411:9411 openzipkin/zipkin
Then config/packages/zipkin.yaml:
zipkin:
service_name: my-app
reporter:
endpoint: '%env(ZIPKIN_ENDPOINT)%' # http://localhost:9411/api/v2/spans
sampler:
type: percentage
rate: 0.1
4. Where to look first. Open vendor/jcchavezs/zipkin-instrumentation-symfony/src and read DependencyInjection/Configuration.php, ZipkinBundle.php, and the event listeners. The bundle is a thin wiring layer over openzipkin/zipkin (v3.0 as of this release); almost everything you will want to customise is a service definition. Run bin/console debug:container zipkin and debug:container --tag=kernel.event_listener | grep -i zipkin to see exactly what got registered — this is the fastest way to confirm the bundle actually booted.
5. First useful outcome. Hit any controller and open http://localhost:9411, select your service name, and you should see one server span per request. If you see nothing, the sampler or reporter is the culprit (see Gotchas), not your controllers.
Let the kernel listener do the boring 90%. The bundle instruments kernel.request/kernel.response/kernel.exception, so every request already has a span with method, path, status code, and duration. Don't hand-roll root spans in controllers; add child spans instead.
Manual spans in a service. Inject Zipkin\Tracing (the bundle's main service) and use the tracer:
public function __construct(private \Zipkin\Tracing $tracing) {}
public function priceCart(Cart $cart): Money
{
$tracer = $this->tracing->getTracer();
$span = $tracer->nextSpan()->setName('cart.price');
$span->start();
$scope = $tracer->openScope($span);
try {
$result = $this->doWork($cart);
$span->tag('cart.items', (string) $cart->count());
return $result;
} catch (\Throwable $e) {
$span->tag('error', $e->getMessage());
throw $e;
} finally {
$scope->close();
$span->finish();
}
}
The Scope wrapper is the key habit: it makes the span the current span so nested work (DB calls, HTTP calls) attaches to it rather than starting a new trace. Always close it in finally.
Tag and annotate for searchability. $span->tag('user.id', $id), $span->tag('cache.hit', 'true'), $span->annotate('payment.authorized'). Tags are indexed in the Zipkin UI and are what you'll actually query during an incident; timestamps-only spans are nearly useless.
Propagate B3 to downstream services. For outbound calls you must inject the X-B3-TraceId/SpanId/ParentSpanId/Sampled headers, otherwise each hop becomes a separate orphan trace. Use the propagation API from the parent library:
$injector = $this->tracing->getPropagation()->getInjector(new \Zipkin\Propagation\Map());
$headers = [];
$injector($span->getContext(), $headers);
Then pass $headers to your HTTP client. If the bundle ships an HTTP-client decorator, prefer it — it's less error-prone than hand-wiring every call site. Do the extraction (getExtractor) on the server side if you sit behind a gateway, or rely on the kernel listener which already reads inbound B3 headers.
Instrument DB and HTTP edges. Enable the bundled Doctrine DBAL middleware (or wrap the connection) so SQL appears as child spans. That is usually where the actual latency lives, and it's the single highest-value instrumentation after the root span.
Long-running workers (Messenger, custom daemons). A kernel.request span does not exist in a worker, so wrap each message handler in a root span and, critically, call the reporter's flush() periodically. A CurlReporter/HTTP reporter buffers spans; in a process that never ends, they may never ship. Either flush on a timer or after each batch.
Test and dev environments. Decorate zipkin.tracing with a no-op, or swap the reporter for a log/console reporter, so you don't pollute your collector — and more importantly so CI doesn't attempt network calls. In Symfony this is a services_test.yaml override:
services:
zipkin.tracing:
class: Zipkin\NoopTracing
openzipkin/zipkin 3.0 for PHP 7.4/8.0/8.1. It does not address Symfony 6's EventDispatcherInterface signature changes or PHP 8.2 deprecations. On a modern stack you still need a fork or a patched listener. Budget a day for this, or pick a maintained alternative.openzipkin/zipkin version will jump. Diff the parent library's changelog, re-run your span-creating code paths, and confirm getTracer()/getPropagation()/openScope() behave as before if you jump from a 2.x lockfile. Lock the parent lib explicitly if you have hand-written instrumentation.Tracing is built lazily and the default sampler may be "never" or misconfigured. If debug:container shows the service but no spans arrive, check the sampler first, then the reporter endpoint (including the /api/v2/spans path — a missing path returns 404 and it's easy to miss in logs).type: always in dev is fine; leaving it in prod floods your collector. Use percentage with a low rate in production, and remember sampling decisions propagate — a 10% sample means 10% of full traces, not 10% of spans.service_name explicitly. Without it, spans land under an unhelpful default and you can't tell environments apart. Prefer my-app-production style names, or tag with deployment.environment.kernel.request is generic. The root span fires before routing, so its name may be the HTTP method or bundle name, not the controller. Rename it in a kernel.controller listener if you want GET /orders/{id}-style names in the UI.Scope — search for missing close()/finish() in try blocks without finally.trace_id_128bit: true) or propagation will silently drop/reject inbound contexts.Zipkin\Sampler, your own Reporter, a decorator over zipkin.tracing, and custom event listeners ordered before/after the bundle's. Avoid patching vendor — these hooks cover almost everything.var/log/dev.log. You'll see immediately whether spans are created (instrumentation problem) or created-but-not-sent (reporter/sampler problem). Those are two very different bugs.How can I help you explore Laravel packages today?