Skip to content

Commit 1537d9c

Browse files
authored
Send each request under the trace context stored on it (#172)
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.
1 parent d8a4aac commit 1537d9c

2 files changed

Lines changed: 100 additions & 2 deletions

File tree

src/RequestInsurance/AsyncRequests/RequestPool.php

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
use GuzzleHttp\Pool;
88
use GuzzleHttp\Client;
99
use GuzzleHttp\TransferStats;
10+
use OpenTelemetry\Context\Context;
1011
use Illuminate\Support\Facades\Config;
1112
use GuzzleHttp\Promise\PromiseInterface;
1213
use Illuminate\Database\Eloquent\Collection;
1314
use Cego\RequestInsurance\Models\RequestInsurance;
15+
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
1416

1517
class RequestPool
1618
{
@@ -76,13 +78,44 @@ protected function requestProvider(Client $client, Collection $requestInsurances
7678
*/
7779
private function convertRequestToPromise(Client $client, RequestInsurance $requestInsurance): PromiseInterface
7880
{
79-
return $client->requestAsync(mb_strtoupper($requestInsurance->method), $requestInsurance->url, [
80-
'headers' => array_merge($requestInsurance->getHeadersCastToArray(), ['User-Agent' => sprintf('RequestInsurance %s', Config::get('app.name', 'unknown'))]),
81+
$headers = $requestInsurance->getHeadersCastToArray();
82+
83+
$send = fn () => $client->requestAsync(mb_strtoupper($requestInsurance->method), $requestInsurance->url, [
84+
'headers' => array_merge($headers, ['User-Agent' => sprintf('RequestInsurance %s', Config::get('app.name', 'unknown'))]),
8185
'body' => $requestInsurance->payload,
8286
'timeout' => $requestInsurance->getEffectiveTimeout(),
8387
'on_stats' => fn (TransferStats $stats) => $requestInsurance->setTimings($stats),
8488
'http_errors' => false,
8589
]);
90+
91+
return $this->withTraceContextOf($headers, $send);
92+
}
93+
94+
/**
95+
* Runs the given callback with the trace context stored on the request insurance as the active context
96+
*
97+
* Auto-instrumentation strips the stored traceparent off the outgoing request and re-injects
98+
* one built from the active context. Without this, every request in a chunk is sent under the
99+
* worker's own span instead of under the trace that created the row.
100+
*
101+
* @param array<string, string> $headers
102+
* @param callable(): PromiseInterface $callback
103+
*
104+
* @return PromiseInterface
105+
*/
106+
private function withTraceContextOf(array $headers, callable $callback): PromiseInterface
107+
{
108+
if ( ! class_exists(TraceContextPropagator::class)) {
109+
return $callback();
110+
}
111+
112+
$scope = Context::storage()->attach(TraceContextPropagator::getInstance()->extract($headers));
113+
114+
try {
115+
return $callback();
116+
} finally {
117+
$scope->detach();
118+
}
86119
}
87120

88121
/**

tests/Unit/RequestPoolTest.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
use Tests\TestCase;
66
use GuzzleHttp\Client;
77
use GuzzleHttp\Psr7\Response;
8+
use OpenTelemetry\API\Trace\Span;
9+
use OpenTelemetry\Context\Context;
810
use GuzzleHttp\Promise\FulfilledPromise;
911
use GuzzleHttp\Promise\PromiseInterface;
1012
use Illuminate\Database\Eloquent\Collection;
13+
use OpenTelemetry\API\Trace\SpanContextInterface;
1114
use Cego\RequestInsurance\Models\RequestInsurance;
1215
use Cego\RequestInsurance\AsyncRequests\RequestPool;
1316

@@ -28,6 +31,68 @@ public function test_it_uppercases_a_mixed_case_method(): void
2831
$this->assertMethodGivenToClient('DeLeTe', 'DELETE');
2932
}
3033

34+
public function test_it_sends_a_request_under_the_trace_context_stored_on_it(): void
35+
{
36+
// Arrange
37+
$traceId = '0af7651916cd43dd8448eb211c80319c';
38+
$spanId = 'b7ad6b7169203331';
39+
40+
$requestInsurance = RequestInsurance::getBuilder()
41+
->url('https://test.lupinsdev.dk')
42+
->method('POST')
43+
->headers(['traceparent' => sprintf('00-%s-%s-01', $traceId, $spanId)])
44+
->create();
45+
46+
// Act
47+
$spanContext = $this->captureActiveSpanContextWhileSending($requestInsurance);
48+
49+
// Assert
50+
$this->assertSame($traceId, $spanContext->getTraceId());
51+
$this->assertSame($spanId, $spanContext->getSpanId());
52+
}
53+
54+
public function test_it_leaves_the_active_context_alone_for_a_request_without_a_trace_context(): void
55+
{
56+
// Arrange
57+
$requestInsurance = RequestInsurance::getBuilder()
58+
->url('https://test.lupinsdev.dk')
59+
->method('POST')
60+
->create();
61+
62+
// Act
63+
$spanContext = $this->captureActiveSpanContextWhileSending($requestInsurance);
64+
65+
// Assert
66+
$this->assertFalse($spanContext->isValid());
67+
}
68+
69+
/**
70+
* Returns the span context that was active while the pool handed the request to the Guzzle client
71+
*
72+
* @param RequestInsurance $requestInsurance
73+
*
74+
* @return SpanContextInterface
75+
*/
76+
protected function captureActiveSpanContextWhileSending(RequestInsurance $requestInsurance): SpanContextInterface
77+
{
78+
$client = new class () extends Client {
79+
public ?SpanContextInterface $spanContext = null;
80+
81+
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
82+
{
83+
$this->spanContext = Span::fromContext(Context::getCurrent())->getContext();
84+
85+
return new FulfilledPromise(new Response(200));
86+
}
87+
};
88+
89+
(new RequestPool($client, new Collection([$requestInsurance])))->getResponses();
90+
91+
$this->assertNotNull($client->spanContext, 'the client was never handed a request');
92+
93+
return $client->spanContext;
94+
}
95+
3196
/**
3297
* Asserts which HTTP method the pool hands to the Guzzle client for a stored method
3398
*

0 commit comments

Comments
 (0)