Skip to content

Send each request under the trace context stored on it - #172

Merged
LauJosefsen merged 1 commit into
masterfrom
lejo/propagate-per-request-trace-context
Aug 11, 2026
Merged

Send each request under the trace context stored on it#172
LauJosefsen merged 1 commit into
masterfrom
lejo/propagate-per-request-trace-context

Conversation

@LauJosefsen

Copy link
Copy Markdown
Contributor

Problem

Every request sent by the worker arrives at its destination under the same parent span, and the trace context captured when the request insurance was created never reaches the wire.

RequestInsuranceBuilder::injectTraceHeaders() stores a traceparent on the row so the request can be tied back to the trace that created it. RequestPool::convertRequestToPromise() passes those stored headers to Client::requestAsync(), so the correct header is on the PSR-7 request.

requestAsync() then calls the private Client::transfer(), which is what open-telemetry/opentelemetry-auto-guzzle hooks. Its pre-hook removes every propagator field from the request:

foreach ($propagator->fields() as $field) {
    $request = $request->withoutHeader($field);
}

TraceContextPropagator::FIELDS is traceparent and tracestate, so the stored context is dropped. The hook then starts a client span with setParent(Context::getCurrent()) and injects that instead.

Inside the worker the active context is the span around the chunk being processed, so every request in a chunk goes out as a child of that one span. The RequestInsuranceInstrumentation batch hook only calls setParent() when a chunk holds exactly one row; for larger chunks it adds links and lets the parent fall back to the ambient context. With the default chunk size of 100 that is effectively every chunk.

Change

RequestPool now activates the trace context stored on the row for the duration of the send. The instrumentation's setParent(Context::getCurrent()) then resolves per request, and the header it injects continues the originating trace rather than the worker's.

Rows without a stored traceparent are unaffected: TraceContextPropagator::extract() returns the current context unchanged when the header is missing or invalid, so the previous behaviour is preserved. The whole thing is skipped when the OpenTelemetry packages are not installed, matching the guard already used in RequestInsuranceBuilder.

One consequence worth calling out: a row retried long after it was created now emits a traceparent pointing at a span that has already finished. That is valid W3C propagation and is the same intent the existing links express, but it does mean retries attach to the original trace rather than starting a fresh one.

The Guzzle auto-instrumentation strips traceparent/tracestate off the
outgoing request and re-injects them from the active context. Since the
pool sends every request in a chunk under the worker's own span, all of
them reached their destination sharing that one parent, and the trace
context recorded when the row was created never made it onto the wire.

Activating the row's own context around the send makes the client span
parent to the trace that created the request insurance instead.
@LauJosefsen LauJosefsen self-assigned this Aug 10, 2026
@LauJosefsen
LauJosefsen merged commit 1537d9c into master Aug 11, 2026
4 checks passed
@LauJosefsen
LauJosefsen deleted the lejo/propagate-per-request-trace-context branch August 11, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants