Skip to content

fix(laravel): queue spans per request signature in ClientRequestWatcher - #711

Open
zigzagdev wants to merge 5 commits into
open-telemetry:mainfrom
zigzagdev:fix/laravel-client-request-watcher-span-collision
Open

fix(laravel): queue spans per request signature in ClientRequestWatcher#711
zigzagdev wants to merge 5 commits into
open-telemetry:mainfrom
zigzagdev:fix/laravel-client-request-watcher-span-collision

Conversation

@zigzagdev

Copy link
Copy Markdown
Contributor

Summary

Motivation

ClientRequestWatcher tracks in-flight HTTP client spans in a single array slot keyed by sha1(method|url|body).
When two requests share the same signature and are in flight at the same time — e.g. identical calls sent through Http::pool(), or a retried request — the second recordRequest() overwrites the first entry in $this->spans.
The overwritten span is never ended (it leaks), and whichever ResponseReceived/ConnectionFailed event fires next gets attributed to the wrong span (or to none, once both slots have been consumed).

What I have done

  • Changed $spans from array<string, SpanInterface> to array<string, list<SpanInterface>>: one queue per request signature instead of one slot.
  • recordRequest() now appends the new span to the queue for its signature.
  • recordResponse()/recordConnectionFailed() now pop the oldest span for that signature via a new shiftSpan() helper (FIFO), instead of reading/deleting a single keyed entry.
  • Concurrent requests with an identical signature are now matched back to their own response/failure in send order, instead of colliding.

Test Plans

  • Added test_it_matches_spans_to_responses_for_concurrent_identical_requests to ClientTest.php: sends two identical GET requests through Http::pool(), with Http::sequence() returning a distinct status (200, then 500) for each, and asserts each recorded span carries the correct status and error state for its own response.
  • Verified this test reproduces the bug: reverting the ClientRequestWatcher.php change while keeping the test causes it to fail (only 1 span recorded instead of 2).

ClientRequestWatcher tracked in-flight spans in a single array slot
keyed by sha1(method|url|body). Concurrent requests with an identical
signature (e.g. via Http::pool(), or retries) overwrote each other's
entry: the overwritten span was never ended (leaked) and the
response/failure event got attributed to the wrong span.

Spans are now queued per signature and matched back to their
RequestSending/ResponseReceived/ConnectionFailed event in FIFO order,
so identical concurrent requests no longer collide.
…tcher

Sends two identical requests through Http::pool() and asserts each
gets its own span correctly matched to its own response, reproducing
the span collision fixed in the previous commit.
@zigzagdev
zigzagdev requested a review from a team as a code owner September 5, 2026 05:14
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.56%. Comparing base (302f221) to head (0fbf52f).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##               main     #711      +/-   ##
============================================
- Coverage     87.11%   85.56%   -1.56%     
- Complexity     1227     1391     +164     
============================================
  Files            86      109      +23     
  Lines          4983     5838     +855     
============================================
+ Hits           4341     4995     +654     
- Misses          642      843     +201     
Flag Coverage Δ
Aws 93.50% <ø> (ø)
Instrumentation/AwsSdk 82.14% <ø> (ø)
Instrumentation/CodeIgniter 79.31% <ø> (ø)
Instrumentation/Doctrine 92.82% <ø> (ø)
Instrumentation/ExtAmqp 88.37% <ø> (ø)
Instrumentation/HttpAsyncClient 78.57% <ø> (ø)
Instrumentation/IO 0.00% <ø> (ø)
Instrumentation/Laravel 76.49% <100.00%> (?)
Instrumentation/Magento2 88.12% <ø> (ø)
Instrumentation/MongoDB 76.84% <ø> (ø)
Instrumentation/OpenAIPHP 86.71% <ø> (ø)
Instrumentation/PostgreSql 91.36% <ø> (ø)
Instrumentation/Psr14 77.41% <ø> (ø)
Instrumentation/Psr15 89.74% <ø> (ø)
Instrumentation/Psr16 97.43% <ø> (ø)
Instrumentation/Psr18 90.24% <ø> (ø)
Instrumentation/Psr6 97.56% <ø> (ø)
Instrumentation/ReactPHP 99.41% <ø> (ø)
Instrumentation/Slim 84.21% <ø> (ø)
Instrumentation/Symfony 84.85% <ø> (ø)
Instrumentation/Yii 83.33% <ø> (ø)
ResourceDetectors/Azure 91.66% <ø> (ø)
ResourceDetectors/DigitalOcean 100.00% <ø> (ø)
Sampler/Xray 78.38% <ø> (ø)
Symfony 88.18% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...tion/Laravel/src/Watchers/ClientRequestWatcher.php 100.00% <100.00%> (ø)

... and 22 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 302f221...0fbf52f. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@zigzagdev
zigzagdev force-pushed the fix/laravel-client-request-watcher-span-collision branch from 60979e8 to 43c1642 Compare September 5, 2026 05:54
…uest

Codecov flagged shiftSpan()'s null-return branch as uncovered on
the fix/laravel-client-request-watcher-span-collision PR: the
existing tests only exercised the path where a response/failure
always has a matching in-flight span.
@zigzagdev
zigzagdev force-pushed the fix/laravel-client-request-watcher-span-collision branch from 43c1642 to 681436e Compare September 5, 2026 05:57

@ChrisLightfootWild ChrisLightfootWild left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an assumption that the response return in order, whereas that may not be the case.

I can see there is already a problem currently, but can we think of any other mechanism to help differentiate these requests? 🤔

The FIFO-per-signature queue introduced to fix span collisions still
assumed responses resolve in the same order requests were sent. That
assumption doesn't hold for concurrent requests (e.g. Http::pool()):
an out-of-order response would pop the wrong queued span and
misattribute its status/attributes.

RequestSending, ResponseReceived, and ConnectionFailed all reference
the same underlying PSR-7 request instance for a given attempt, so
spans are now keyed by spl_object_id() of that instance instead of a
content hash. This removes the ordering assumption entirely: two
in-flight requests with identical method/URL/body can never share a
key, regardless of resolution order.
Directly exercises ClientRequestWatcher with two identical in-flight
requests, resolving the later-sent one first, and inspects the
internal span map via reflection to confirm only the resolved
request's entry is removed. This fails under the previous
FIFO-per-signature implementation, which pops by send order rather
than by which request actually responded.
@zigzagdev

Copy link
Copy Markdown
Contributor Author

@ChrisLightfootWild

I can see there is already a problem currently, but can we think of any other mechanism to help differentiate these requests? 🤔

Yes, there's a better mechanism: correlating requests by object identity instead of content.
So I searched the correct way and fixed and added new test at here.

Firstly, the FIFO-per-signature queue still assumed responses resolve in the same order requests were sent.
That assumption doesn't hold for concurrent requests (e.g. Http::pool()).
If a response resolves out of order, the queue pops the wrong span and misattributes its status and attributes to the wrong request.

I considered a few ways to fix this:

  1. Keep content-hash + FIFO, try to fix the ordering somehow
    Not really fixable. The content hash (method|url|body) carries no information about which in-flight request a given response belongs to. Any queue or ordering-based matching is fundamentally guessing based on send order, and send order isn't guaranteed to match completion order.

  2. SplObjectStorage keyed by the underlying PSR-7 request object
    RequestSending, ResponseReceived, and ConnectionFailed all reference the same PSR-7 request instance for a given attempt (PendingRequest.php#L238-242, #L1397-1416).
    Keying by object identity removes the ordering assumption entirely: two in-flight requests with identical content always get distinct keys, regardless of resolution order.
    SplObjectStorage holds a strong reference to that key object, making it the textbook-safe way to do object-identity keying in PHP.

Drawback: it pins the entire PSR-7 request (headers, body, etc.) in memory for as long as the entry sits unresolved.
If a ResponseReceived/ConnectionFailed event is ever missed for some reason (a pre-existing risk this watcher already has), that becomes a heavier leak than today's: a whole request object per leaked entry instead of a short hash string.

  1. spl_object_id() of the underlying PSR-7 request as a plain int key (what I went with)
    Same identity-based correctness as Initial commit with actions #2, but it stores only a scalar int per in-flight span instead of holding the request object itself.
    Drawback: spl_object_id() ids are only unique among currently-living objects.
    Once an object is destroyed, PHP may reuse its id for something else (php.net: "When an object is destroyed, its id may be reused for other objects.").
    If the PSR-7 request were freed before its matching response/failure event fires, a later, unrelated object could theoretically reuse the same id and get its span misattributed.

Why did i chose 3

In this specific code path, the request object is kept alive by Guzzle's handler/promise chain for the entire in-flight duration.
We're not relying on anything outside that, so the reuse window doesn't open before the correlating event fires in practice.
Given that, I preferred the lighter-weight option over paying #2's memory cost for a guarantee we already get for free here.
I'm happy to switch to SplObjectStorage if you'd rather have the belt-and-suspenders version regardless.

And I also added ClientRequestWatcherTest::test_it_matches_the_correct_span_when_responses_resolve_out_of_order, which resolves the later-sent of two identical requests first and verifies (via reflection on the internal span map) that only the resolved request's entry is removed.
It fails against the old FIFO implementation and passes against this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants