open-telemetry/exporter-zipkin
Zipkin exporter for OpenTelemetry PHP, sending traces to Zipkin for visualization and analysis. Configure via the OpenTelemetry PHP SDK; read-only subtree split of the main monorepo.
This package is a plug-in exporter only — it converts finished OpenTelemetry spans into Zipkin v2 JSON and POSTs them to a collector. It does not instrument anything, so on day one you are really wiring up three things: the OpenTelemetry SDK (tracer provider + processor), this exporter, and a Zipkin endpoint to look at.
Minimum viable setup (≈10 minutes):
docker run -d -p 9411:9411 openzipkin/zipkin
composer require open-telemetry/sdk open-telemetry/exporter-zipkin guzzlehttp/guzzle
config/telemetry.php with endpoint (must include the path — http://zipkin:9411/api/v2/spans), service_name, and sample_ratio.TracerProvider singleton in a service provider and expose it as the global provider:
use OpenTelemetry\Contrib\Zipkin\Exporter;
use OpenTelemetry\SDK\Trace\{TracerProvider, SpanProcessor\BatchSpanProcessor};
use OpenTelemetry\SDK\Trace\Sampler\{ParentBased, TraceIdRatioBasedSampler};
$this->app->singleton(TracerProvider::class, function () {
$exporter = new Exporter(
config('telemetry.endpoint'), // full URL, incl. /api/v2/spans
config('telemetry.service_name'), // becomes Zipkin localEndpoint.serviceName
);
return new TracerProvider(
new BatchSpanProcessor($exporter),
new ParentBased(new TraceIdRatioBasedSampler(config('telemetry.sample_ratio'))),
);
});
// publish to OpenTelemetry\API\Globals::tracerProvider()
Sdk::builder()
->setTracerProvider($this->app->make(TracerProvider::class))
->buildAndRegisterGlobal();
Constructor signature changed at 1.0 — verify against your installed version; pre-1.0 betas used fromConnectionString().$this->app->terminating(fn () => $this->app->make(TracerProvider::class)->shutdown());
First use case: a single root span around one endpoint. Add a middleware that starts a span with SpanKind::KIND_SERVER, ends it in a finally, hit a slow route, and confirm it appears in the Zipkin UI at localhost:9411 filtered by your service name. Do not attempt DB/queue/HTTP-client instrumentation until this one span round-trips — the majority of first-day failures are transport/config, not instrumentation.
Where to read: the exporter example at examples/traces/exporters/zipkin.php in the monorepo (the standalone README is a stub), plus the OpenTelemetry PHP exporters doc page linked from it.
Root span via middleware, children via ambient context. Once a span is activated, everything downstream that asks Globals::tracerProvider()->getTracer(...) becomes a child automatically. Keep the root middleware dumb; push domain knowledge into small helpers.
$span = $tracer->spanBuilder($request->method().' '.$request->route()?->uri())
->setSpanKind(SpanKind::KIND_SERVER)
->setAttribute('http.request.method', $request->method())
->startSpan();
$scope = $span->activate();
try {
$response = $next($request);
$span->setAttribute('http.response.status_code', $response->getStatusCode());
return $response;
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(Status::STATUS_ERROR);
throw $e;
} finally {
$scope->detach();
$span->end();
}
Instrument at Laravel's seams, not inside your code. These cover ~90% of real trace value with almost no invasiveness:
DB::listen(fn ($q) => $span->setAttribute('db.statement', $q->sql)), or wrap DB::transaction(); better still, add a query count/duration attribute and leave the raw SQL out (see Gotchas).Http::globalMiddleware() (Laravel 11+) or a Guzzle Middleware::mapRequest() that injects the propagation header and a Middleware::mapResponse() that closes the span. Set server.address/server.port so Zipkin can draw the dependency graph.Queue::before / Queue::after events, or a job middleware: start a CONSUMER span in handle(), end it after $next($job). Set messaging.system/messaging.destination.name.Artisan::starting() or your commands' handle().Batch processor in production, simple processor in tests. BatchSpanProcessor keeps the request path hot but defers I/O; swap in SimpleSpanProcessor (or the in-memory/console exporter) when you want deterministic assertions in Pest/PHPUnit instead of sleeping and curling Zipkin.
Sampling is the provider's job, not the exporter's. Use ParentBased(TraceIdRatioBasedSampler(0.1)) in production and 1.0 locally. The exporter has no sampling logic — an unsampled span never reaches it.
Propagation: the SDK defaults to W3C traceparent. Zipkin-native ecosystems (Spring Sleuth, older services) speak B3 — install open-telemetry/extension-propagator-b3 and configure the propagator so cross-service traces stitch together.
Name spans for humans searching the UI. Zipkin's primary index is serviceName + span name. Use the route pattern (GET /orders/{order}), not the resolved path; put the status code and controller in tags. Reserve zipkin.kind conventions for internal spans — SpanKind::KIND_INTERNAL lands as a tag rather than Zipkin's kind field, so it won't appear in the client/server call graph.
Correlate traces with logs. Add a Monolog processor that reads Span::getCurrent()->getContext()->getTraceId() and stamps trace_id on every log line. This is the single highest-value follow-up to shipping the exporter — you can jump from a Zipkin trace to the exact Laravel log lines.
If you already run an OTel Collector, prefer open-telemetry/exporter-otlp from the app and let the Collector fan out to Zipkin (and anything else). It decouples your app from the backend and gives you retry/buffering you won't get from a PHP exporter.
BatchSpanProcessor that is never flushed drops everything queued at request end. $provider->shutdown() in app()->terminating() is mandatory. Under Octane/Queue workers the provider persists — flush on WorkerStopping/Looping, not per request.http://zipkin:9411 fails silently-ish; you need /api/v2/spans. Test with a raw curl -X POST before blaming the exporter.localEndpoint.serviceName, and it does not automatically follow OTEL_SERVICE_NAME. Mismatch here makes every trace appear under a phantom service in the UI.json_encode() them yourself or you'll get dropped data or warnings.recordException() (bounded) and length limits (OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT). Set OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT deliberately.php-http/discovery. Install guzzlehttp/guzzle (plus nyholm/psr7 if there's no PSR-17 factory) or symfony/http-client.export() in try/catch (\Throwable) and returns ExportResult::failed(...) — export exceptions in some versions propagate to the span processor.main now requires PHP 8.2+ and 1.2.0 targets 8.1+. Pin the SDK and exporter together, expect the mirror to lag, and file issues/PRs against open-telemetry/opentelemetry-php, never the subtree.SimpleSpanProcessor (removes queueing from the equation), then point the exporter at a local nc -l / request-capturing proxy, then check OTEL_LOG_LEVEL=debug output. Only after the HTTP call is confirmed should you question the instrumentation.How can I help you explore Laravel packages today?