open-telemetry/opentelemetry-propagation-traceresponse
Adds TraceResponse propagation support to the OpenTelemetry PHP SDK, letting services extract and inject trace context using the draft TraceResponse format. Useful when integrating with systems that emit or expect this header, keeping distributed traces connected across service boundaries.
What it is: a contrib propagator that implements the Trace Context: traceresponse draft — the response-side companion to traceparent. Your server emits a traceresponse header on the HTTP response carrying the trace-id and the span-id of the server's root span, so a caller (including a browser) can correlate what it just saw with the server-side trace. It is additive: you still need traceparent/TraceContextPropagator for normal request propagation.
Install and wire up:
composer require open-telemetry/opentelemetry-propagation-traceresponse
use OpenTelemetry\Contrib\Propagation\TraceResponse\TraceResponsePropagator;
// Response side only — do NOT add this to your outgoing-request propagator list.
$propagator = new TraceResponsePropagator();
$carrier = [];
$propagator->inject($carrier);
// ['traceresponse' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01']
Then attach it to the outgoing PSR-7 response:
foreach ($carrier as $name => $value) {
$response = $response->withHeader($name, $value);
}
Where to look first:
src/ — one small propagator class (verify the exact FQCN/namespace against composer.json's autoload block; contrib packages follow the OpenTelemetry\Contrib\Propagation\{Name}\{Name}Propagator convention, and its sibling opentelemetry-propagation-server-timing is structurally identical).composer.json — the open-telemetry/sdk and api version constraints. This repo is [READONLY] with a single dependent-free release line, so pin versions and read the constraint before upgrading the SDK.tests/ — the smallest reliable spec of header shape and no-op behaviour.First use case: put it in a PSR-15 / Symfony / Laravel response middleware after the tracing middleware has started the root span, so a client that received a 500 can quote traceresponse in a bug report and you can jump straight to the trace.
Middleware-first integration (the normal shape). Add a thin response middleware rather than touching every controller:
final class TraceResponseMiddleware implements MiddlewareInterface
{
public function __construct(private TextMapPropagatorInterface $propagator) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$carrier = [];
$this->propagator->inject($carrier);
foreach ($carrier as $name => $value) {
$response = $response->withHeader($name, $value);
}
return $response;
}
}
Keep two propagator sets. Do not register this in TracerProvider::registerPropagator() if that same provider injects into outbound Guzzle/Symfony HTTP client calls — otherwise you'll ship traceresponse on requests, which is meaningless and confuses proxies. In practice:
// Request propagation (client + extract on server)
$requestPropagators = new CompositePropagator([
new TraceContextPropagator(),
new BaggagePropagator(),
]);
// Response propagation (a separate instance, used only by response middleware)
$responsePropagator = new TraceResponsePropagator();
Span-order matters. inject() sources the current SpanContext. If your root span ends (and detaches) before the middleware runs, you get nothing. Either keep the root span active until after the response is built, or capture the SpanContext when the root span is created and inject from an explicit Context::withContext(...)/Scope at response time.
Browser correlation. If a frontend reads it with fetch()/XHR, you must add it to CORS:
Access-Control-Expose-Headers: traceresponse
and, when the browser is on another origin, this header is also a custom response header — no preflight implication for responses, but the expose header is mandatory or JS sees null.
Correlation, not causality. If you also want the client side to record the linkage, call extract() and store the returned SpanContext as an attribute/log field (e.g. server.trace_id) on the client span. Do not pass it as a remote parent — that inverts the causal direction and makes your client span a child of a span that happened after it.
Framework wiring (Laravel/Symfony). Register the middleware in the global (not route) stack so 404s, 500s, and exception-handler-rendered responses all get the header. For Symfony, tag it with kernel.response / a high-priority listener; for Laravel, $middleware in app/Http/Kernel.php — the terminate/response path is where the span context is still reachable.
Testing. Assert output with a real SDK provider, not a mock:
$tracerProvider = new TracerProvider(new SimpleSpanProcessor(new InMemoryExporter($spans)));
$tracer = $tracerProvider->getTracer('test');
$scope = $tracer->spanBuilder('root')->activate();
$carrier = [];
(new TraceResponsePropagator())->inject($carrier);
$scope->detach();
$this->assertMatchesRegularExpression('/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/', $carrier['traceresponse']);
Test the no-active-span case too — that's the one that silently regresses.
Complementary pairs. Use alongside open-telemetry/opentelemetry-propagation-server-timing, which uses the identical response-only propagator pattern; wrapping both in one response middleware keeps the plumbing in a single place.
inject() on a propagator with no current SpanContext writes nothing — no exception, no log. If the header is "missing in prod but passes in tests", 90% of the time the span is already detached. Add an assertion/log in the middleware when $carrier is empty.traceresponse on every Guzzle call. Server-side-only headers belong in a dedicated instance.traceresponse when the request is unsampled? Behaviour depends on whether the SDK yields a valid non-recording SpanContext vs. none. Check the flags you emit (00 vs 01) against the draft and don't assume sampled-only.00) have changed during drafting. Pin the package, pin open-telemetry/sdk, and re-run your regex test on every bump. Since the repo is [READONLY] with 0 dependents and a single star, budget for maintaining a fork if upstream stalls.extract() exists for correlation convenience; using it as a substitute for TraceContextPropagator gives you broken trace trees. Requests still need traceparent.traceresponse in middleware and in Access-Control-Expose-Headers.traceresponse on a /health route to bisect the stack fast.TextMapPropagatorInterface implementation — if you need extras (e.g. an additional custom header, or suppressing emission for certain routes), write a 30-line decorator around it rather than forking; that keeps you upgrade-compatible with contrib.fields(). It declares ['traceresponse']; use it to whitelist the header in any proxy/CDN config that strips unknown response headers, and in log-middleware that records response headers.How can I help you explore Laravel packages today?