Skip to content

Commit 7d534a0

Browse files
authored
fix guzzle promise handling (#393)
In certain circumstances (eg Curl handler and middlewares configured such that no additional promise chaining occurs), our post transfer hook can receive an already-resolved Promise. Since it's already resolved, our extra callbacks won't be applied in a timely fashion (they're instead added to a queue and will be processed as part of a shutdown handler, if not triggered by something earlier). The upshot of this is a lot of memory being held on to for each request, and it seems that the default config when we use guzzle as our SDK exporter will trigger this. The spans for export are non-recording, so nothing user-visible happens, but the non-recording spans and promise callbacks are held in memory, seemingly forever or until shutdown. The fix for this is to check is the promise is already resolved, and if so force our extra promise to resolve.
1 parent 42f6a4b commit 7d534a0

File tree

2 files changed

+61
-5
lines changed

2 files changed

+61
-5
lines changed

src/Instrumentation/Guzzle/src/GuzzleInstrumentation.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use function get_cfg_var;
88
use GuzzleHttp\Client;
99
use GuzzleHttp\Exception\BadResponseException;
10+
use GuzzleHttp\Promise\Is;
1011
use GuzzleHttp\Promise\PromiseInterface;
1112
use OpenTelemetry\API\Globals;
1213
use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
@@ -101,7 +102,7 @@ public static function register(): void
101102
$span->end();
102103
}
103104

104-
$promise->then(
105+
$p = $promise->then(
105106
onFulfilled: function (ResponseInterface $response) use ($span) {
106107
$span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode());
107108
$span->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $response->getProtocolVersion());
@@ -117,8 +118,6 @@ public static function register(): void
117118
$span->setStatus(StatusCode::STATUS_ERROR);
118119
}
119120
$span->end();
120-
121-
return $response;
122121
},
123122
onRejected: function (\Throwable $t) use ($span) {
124123
if ($t instanceof BadResponseException && $t->hasResponse()) {
@@ -130,10 +129,13 @@ public static function register(): void
130129
$span->recordException($t);
131130
$span->setStatus(StatusCode::STATUS_ERROR, $t->getMessage());
132131
$span->end();
133-
134-
throw $t;
135132
}
136133
);
134+
135+
//if the original promise is already settled, force our additional promise to execute immediately
136+
if (Is::settled($promise)) {
137+
$p->wait();
138+
}
137139
}
138140
);
139141
}

src/Instrumentation/Guzzle/tests/Integration/GuzzleInstrumentationTest.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
use GuzzleHttp\Exception\ConnectException;
1010
use GuzzleHttp\Handler\MockHandler;
1111
use GuzzleHttp\HandlerStack;
12+
use GuzzleHttp\Promise\FulfilledPromise;
1213
use GuzzleHttp\Promise\PromiseInterface;
14+
use GuzzleHttp\Promise\RejectedPromise;
1315
use GuzzleHttp\Promise\Utils;
1416
use Nyholm\Psr7\Request;
1517
use Nyholm\Psr7\Response;
1618
use OpenTelemetry\API\Instrumentation\Configurator;
1719
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
20+
use OpenTelemetry\API\Trace\StatusCode;
1821
use OpenTelemetry\Context\ScopeInterface;
1922
use OpenTelemetry\SDK\Trace\ImmutableSpan;
2023
use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter;
@@ -164,7 +167,58 @@ public function test_headers_propagation(): void
164167

165168
$this->mock->append(new Response());
166169
$this->client->get('/');
170+
}
171+
172+
/**
173+
* @dataProvider promiseProvider
174+
* @link https://github.com/open-telemetry/opentelemetry-php/issues/1623
175+
*/
176+
public function test_transfer_promises_execute_for_sync_requests(PromiseInterface $promise, bool $expectedException): void
177+
{
178+
/**
179+
* Approximate the Curl handler's behaviour of returning a FulfilledPromise without any
180+
* additional promise chaining, so that post hook receives a FulfilledPromise.
181+
*/
182+
$handler = function () use ($promise): PromiseInterface {
183+
return $promise;
184+
};
185+
$handlerStack = HandlerStack::create($handler);
186+
187+
$client = new Client([
188+
'handler' => $handlerStack,
189+
'base_uri' => 'https://example.com/',
190+
'http_errors' => false,
191+
'allow_redirects' => false,
192+
]);
193+
$this->assertCount(0, $this->storage);
194+
195+
$request = new Request('GET', 'https://example.com/foo');
167196

197+
try {
198+
$client->send($request);
199+
} catch (\Throwable $e) {
200+
if (!$expectedException) {
201+
throw $e;
202+
}
203+
}
204+
205+
$this->assertCount(1, $this->storage);
206+
$span = $this->storage->offsetGet(0);
207+
assert($span instanceof ImmutableSpan);
208+
if ($expectedException) {
209+
$this->assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode());
210+
$this->assertSame('Test exception', $span->getStatus()->getDescription());
211+
} else {
212+
$this->assertSame(201, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE));
213+
}
214+
}
215+
216+
public static function promiseProvider(): array
217+
{
218+
return [
219+
'fulfilled' => [new FulfilledPromise(new Response(201)), false],
220+
'rejected' => [new RejectedPromise(new \RuntimeException('Test exception')), true],
221+
];
168222
}
169223

170224
/**

0 commit comments

Comments
 (0)