open-telemetry/opentelemetry-propagation-server-timing
Propagates OpenTelemetry trace context through the Server-Timing HTTP header, helping PHP applications correlate backend traces with browser and frontend requests for simpler debugging and performance analysis.
Minimal install
composer require open-telemetry/opentelemetry-propagation-server-timing
This is a propagator, not an exporter — on its own it produces nothing. You need open-telemetry/sdk, a tracer provider, and an exporter already wired up (OTLP, console, whatever you use). The propagator's whole job is to write trace context into the HTTP response's Server-Timing header so that a browser-side consumer (frontend OTel SDK, RUM agent, custom JS) can extract it and stitch frontend spans onto the backend trace.
Enable it
Contrib packages in this family ship a _register.php in the Composer files autoload that registers the propagator factory with the SDK Registry under a short name (typically server-timing). Once installed, opt in via config rather than code:
OTEL_PROPAGATORS=tracecontext,baggage,server-timing
If your bootstrap builds the provider manually, register it explicitly instead:
use OpenTelemetry\SDK\Registry;
use OpenTelemetry\Contrib\Propagation\ServerTiming\ServerTimingPropagatorFactory;
Registry::registerTextMapPropagatorFactory('server-timing', new ServerTimingPropagatorFactory());
Where to look first
src/ — the propagator class itself. Read fields() and inject(); the header name(s) it emits and the exact Server-Timing syntax it uses (entry name, desc, whether it also writes dur) are the contract you'll be parsing on the frontend and appending to on the backend._register.php — confirms the registry key you must put in OTEL_PROPAGATORS.PropagationSetterInterface it expects for a PSR-7/PSR-15 response, since Laravel does not use PSR-7.First use case: open DevTools → Network → any request → Timing tab, confirm an entry named after the propagator appears, then curl -sI https://your.app/ | grep -i server-timing and check that the value round-trips into a traceparent on the frontend. That five-minute loop validates install, registration, and injection point before you touch application code.
Propagation must happen after the response object exists but before it is sent. A middleware wrapping $next($request) is the correct seam:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\Span;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
use Symfony\Component\HttpFoundation\Response;
class ServerTimingTrace
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$context = Span::getCurrent()->getContext();
if (!$context->isValid()) {
return $response;
}
// Array carrier + array setter: avoids depending on a PSR-7 setter
// from a package that has never seen a Symfony Response.
$carrier = [];
Globals::propagator()->inject($carrier, new ArrayAccessGetterSetter(), $context);
if (!empty($carrier['Server-Timing'])) {
// third arg = false => append, do not clobber existing metrics
$response->headers->set('Server-Timing', $carrier['Server-Timing'], false);
}
return $response;
}
}
The array-carrier trick is the single most useful pattern here: it decouples you from whatever setter the package documents, and works identically for Symfony responses, JsonResponse, and streamed responses.
Server-Timing is a list header and your app (or a profiler bundle) probably already writes app;dur=12.3. Symfony's HeaderBag::set($key, $values, $replace = true) — pass false. If you're on raw PHP or another framework, join with , . Keep the OTel entry last so your own metrics stay human-readable in DevTools.
Inject the local root span's context, not the currently active child span, if you want the browser's traceparent to point at the trace as a whole:
$rootSpan = Span::getCurrent(); // inside middleware this is usually the server span
$context = $rootSpan->getContext();
If you have custom request-scoped spans, resolve the intended one explicitly and store it via Context::storage() in an earlier middleware rather than relying on ambient state — especially with Octane, where "ambient" is a moving target.
Globals::propagator() returns the composite propagator from OTEL_PROPAGATORS. If you list baggage alongside this package, baggage entries land in Server-Timing too — a public header. Either:
$propagator = new \OpenTelemetry\API\Propagation\MultiTextMapPropagator([
new \OpenTelemetry\API\Propagation\TraceContextPropagator(),
new ServerTiming(),
]);
baggage key for PII/tenant IDs.Expose the header and parse it in the browser:
$response->headers->set('Access-Control-Expose-Headers', 'Server-Timing', false);
// @opentelemetry/sdk-trace-web
const entry = performance.getEntriesByType('resource')[0]
?.serverTiming?.find(e => e.name.includes('trace'));
// build a carrier from the parsed value, then:
propagation.extract(context.active(), { traceparent: value });
performance resource timing is the natural reader — no XHR hook needed — but sampling of resource timing buffers and cross-origin restrictions apply.
Feature-test the header directly; you don't need a real collector:
$response = $this->get('/');
$response->assertHeader('Server-Timing');
$this->assertStringContainsString('traceparent', $response->headers->get('Server-Timing'));
Bypass the middleware with withoutMiddleware(ServerTimingTrace::class) in the rest of your suite so header assertions elsewhere don't get noisy, and run one test with an in-memory exporter to confirm the trace ID in the header matches the root span.
bootstrap/app.php → $middleware->append()), after whatever middleware establishes the trace, and before SubstituteBindings is irrelevant — order only matters relative to the tracing middleware.web group only if you don't want machine-to-machine API traffic carrying trace IDs in a public header.Vary or explicit CDN cache-key rule if you cache HTML (see Gotchas).isValid() check._register.php and stale autoloaders. After install, run composer dump-autoload. A missing registration fails silently — no exception, no header, no warning. First debugging step is always composer show -a open-telemetry/opentelemetry-propagation-server-timing plus confirming the factory key appears in your effective OTEL_PROPAGATORS.
Injecting in terminate() is too late. Laravel's terminating middleware runs after the response is flushed; the header will never reach the client. Same trap with StreamedResponse — set headers before invoking the callback.
Header overwrite kills your own metrics. The most common integration bug: $response->headers->set('Server-Timing', $value) (default $replace = true) silently destroys whatever your profiler or APM agent put there. Always append.
Full-page caches and CDNs corrupt trace topology. If a cached response ships the original request's traceparent, every subsequent visitor inherits a stale trace ID and your browser spans attach to a months-old trace. Strip Server-Timing in your cache layer, or exclude the header from the cache key and regenerate it on the edge.
Some proxies drop or rewrite Server-Timing. It's frequently treated as infrastructure noise by CDNs and WAFs. Verify end-to-end through your real edge, not just from localhost.
Public header, public data. Server-Timing is visible to any client and to browser tooling. Trace IDs are low-risk, but desc fields carrying span names, tenant IDs, or baggage are a real disclosure. Sanitize or inject only the trace-context propagator.
Sampling asymmetry. A non-sampled trace still yields a valid traceparent (flag 00). Decide whether to emit it at all: emitting lets the frontend create spans that are then dropped at the collector; omitting keeps the header honest but breaks correlation when the backend is sampled and the frontend isn't configured to respect the flag. Make the choice explicit in the middleware.
Octane / Swoole context bleed. Long-lived workers keep PHP process state across requests. Never cache the injected carrier or a propagator instance on a singleton; resolve Globals::propagator() per request, and ensure the tracing middleware properly detaches scopes so request N's context isn't injected into request N+1's response.
Pre-1.0, zero dependents, one star. Treat the API as unstable: pin the exact version in composer.json, and wrap the injection in your own tiny class (ServerTimingInjector) that returns a plain string. When the package churns or is abandoned — a real risk for a READONLY contrib module with no dependents — you swap one 20-line class instead of hunting call sites.
Version/date sanity. The listed last release date is ahead of today, which is either a mirroring artifact or a re-tagged build. Diff the tag against the previous one before upgrading; don't assume semantic versioning discipline.
Custom wire format? If you need a different entry name, a dur value, or a second header, don't fork — implement TextMapPropagatorInterface in ~30 lines and compose it with MultiTextMapPropagator alongside TraceContextPropagator. The interface is stable even when this package isn't; that's your real extension point.
How can I help you explore Laravel packages today?