diff --git a/.gitignore b/.gitignore index 8184b294..0d0f1747 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ vendor composer.lock .php_cs.cache .phpunit.result.cache +*.har diff --git a/composer.json b/composer.json index e31e85bf..add287bf 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ ], "require": { "php": ">=7.2", - "amphp/amp": "^2.2", + "amphp/amp": "^2.4", "amphp/byte-stream": "^1.6", "amphp/hpack": "^2", "amphp/http": "^1.3", diff --git a/examples/basic/1-get-request.php b/examples/basic/1-get-request.php index ca6eadff..b860b6c8 100644 --- a/examples/basic/1-get-request.php +++ b/examples/basic/1-get-request.php @@ -29,7 +29,7 @@ dumpResponseBodyPreview(yield $response->getBody()->buffer()); } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/examples/basic/2-custom-header.php b/examples/basic/2-custom-header.php index 2460d0e2..888ff582 100644 --- a/examples/basic/2-custom-header.php +++ b/examples/basic/2-custom-header.php @@ -32,7 +32,7 @@ dumpResponseBodyPreview(yield $response->getBody()->buffer()); } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/examples/basic/3-post-body.php b/examples/basic/3-post-body.php index 1865d291..95091cbe 100644 --- a/examples/basic/3-post-body.php +++ b/examples/basic/3-post-body.php @@ -34,7 +34,7 @@ dumpResponseBodyPreview(yield $response->getBody()->buffer()); } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/examples/basic/4-forms.php b/examples/basic/4-forms.php index f3907987..3c568eeb 100644 --- a/examples/basic/4-forms.php +++ b/examples/basic/4-forms.php @@ -40,7 +40,7 @@ dumpResponseBodyPreview(yield $response->getBody()->buffer()); } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/examples/basic/5-unix-sockets.php b/examples/basic/5-unix-sockets.php index 7855f248..29984992 100644 --- a/examples/basic/5-unix-sockets.php +++ b/examples/basic/5-unix-sockets.php @@ -39,7 +39,7 @@ dumpResponseBodyPreview(yield $response->getBody()->buffer()); } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/examples/basic/6-customization.php b/examples/basic/6-customization.php index 8b623066..3a8506df 100644 --- a/examples/basic/6-customization.php +++ b/examples/basic/6-customization.php @@ -2,6 +2,7 @@ use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\HttpException; +use Amp\Http\Client\Interceptor\LogIntoHttpArchive; use Amp\Http\Client\Interceptor\MatchOrigin; use Amp\Http\Client\Interceptor\SetRequestHeader; use Amp\Http\Client\Request; @@ -13,21 +14,24 @@ Loop::run(static function () use ($argv) { try { $client = (new HttpClientBuilder) + ->intercept(new LogIntoHttpArchive(__DIR__ . '/log.har')) ->intercept(new MatchOrigin(['https://amphp.org' => new SetRequestHeader('x-amphp', 'true')])) ->followRedirects(0) ->retry(3) ->build(); - /** @var Response $response */ - $response = yield $client->request(new Request($argv[1] ?? 'https://httpbin.org/user-agent')); + for ($i = 0; $i < 5; $i++) { + /** @var Response $response */ + $response = yield $client->request(new Request($argv[1] ?? 'https://httpbin.org/user-agent')); - dumpRequestTrace($response->getRequest()); - dumpResponseTrace($response); + dumpRequestTrace($response->getRequest()); + dumpResponseTrace($response); - dumpResponseBodyPreview(yield $response->getBody()->buffer()); + dumpResponseBodyPreview(yield $response->getBody()->buffer()); + } } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/examples/basic/7-gzip.php b/examples/basic/7-gzip.php index 146a30fc..66d0fa4d 100644 --- a/examples/basic/7-gzip.php +++ b/examples/basic/7-gzip.php @@ -22,7 +22,7 @@ dumpResponseBodyPreview(yield $response->getBody()->buffer()); } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/examples/concurrency/1-concurrent-fetch.php b/examples/concurrency/1-concurrent-fetch.php index ff6f0b63..ffefb070 100644 --- a/examples/concurrency/1-concurrent-fetch.php +++ b/examples/concurrency/1-concurrent-fetch.php @@ -39,7 +39,7 @@ } } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/examples/concurrency/3-benchmark.php b/examples/concurrency/3-benchmark.php index 225fd4ff..8b89c4fa 100644 --- a/examples/concurrency/3-benchmark.php +++ b/examples/concurrency/3-benchmark.php @@ -13,7 +13,7 @@ use Amp\Socket\ClientTlsContext; use Amp\Socket\ConnectContext; use function Amp\coroutine; -use function Amp\Internal\getCurrentTime; +use function Amp\getCurrentTime; require __DIR__ . '/../.helper/functions.php'; @@ -21,7 +21,7 @@ Loop::run(static function () use ($count, $argv) { // Disable peer verification (not recommended, but we use a random test certificate here) - $tlsContext = (new ClientTlsContext) + $tlsContext = (new ClientTlsContext('')) ->withoutPeerVerification(); $connectContext = (new ConnectContext) @@ -33,7 +33,7 @@ $handler = coroutine(static function (int $count) use ($client, $argv) { for ($i = 0; $i < $count; $i++) { - $request = new Request($argv[2] ?? 'http://localhost:1337/'); + $request = new Request($argv[2] ?? 'https://localhost:1338/'); $request->setTcpConnectTimeout(1000); $request->setTlsHandshakeTimeout(1000); $request->setTransferTimeout(1000); diff --git a/examples/streaming/1-large-response.php b/examples/streaming/1-large-response.php index 498ad88b..b753eb2a 100644 --- a/examples/streaming/1-large-response.php +++ b/examples/streaming/1-large-response.php @@ -88,7 +88,7 @@ function formatBytes(int $size, int $precision = 2) print \sprintf("%s has a size of %.2fMB\r\n", $path, (float) $size / 1024 / 1024); } catch (HttpException $error) { // If something goes wrong Amp will throw the exception where the promise was yielded. - // The Client::request() method itself will never throw directly, but returns a promise. + // The HttpClient::request() method itself will never throw directly, but returns a promise. echo $error; } }); diff --git a/src/Connection/Http1Connection.php b/src/Connection/Http1Connection.php index 8d20bec3..4dfbea91 100644 --- a/src/Connection/Http1Connection.php +++ b/src/Connection/Http1Connection.php @@ -12,6 +12,7 @@ use Amp\Emitter; use Amp\Http; use Amp\Http\Client\Connection\Internal\Http1Parser; +use Amp\Http\Client\HarAttributes; use Amp\Http\Client\HttpException; use Amp\Http\Client\Internal\ForbidCloning; use Amp\Http\Client\Internal\ForbidSerialization; @@ -35,6 +36,7 @@ use Amp\TimeoutCancellationToken; use function Amp\asyncCall; use function Amp\call; +use function Amp\getCurrentTime; /** * Socket client implementation. @@ -188,7 +190,9 @@ private function request(Request $request, CancellationToken $cancellation): Pro $id = $combinedCancellation->subscribe([$this, 'close']); try { + $request->setAttribute(HarAttributes::TIME_SEND, getCurrentTime()); yield from $this->writeRequest($request, $protocolVersion, $combinedCancellation); + $request->setAttribute(HarAttributes::TIME_WAIT, getCurrentTime()); return yield from $this->readResponse($request, $cancellation, $combinedCancellation); } finally { $combinedCancellation->unsubscribe($id); @@ -252,8 +256,15 @@ private function readResponse( $parser = new Http1Parser($request, $bodyCallback, $trailersCallback); + $firstRead = true; + try { while (null !== $chunk = yield $this->socket->read()) { + if ($firstRead) { + $request->setAttribute(HarAttributes::TIME_RECEIVE, getCurrentTime()); + $firstRead = false; + } + $response = $parser->parse($chunk); if ($response === null) { continue; @@ -326,6 +337,8 @@ private function readResponse( $this->busy = false; + $request->setAttribute(HarAttributes::TIME_COMPLETE, getCurrentTime()); + $bodyEmitter->complete(); $trailersDeferred->resolve($trailers); } catch (\Throwable $e) { diff --git a/src/Connection/Http2Connection.php b/src/Connection/Http2Connection.php index 72cb2b83..aebedcba 100644 --- a/src/Connection/Http2Connection.php +++ b/src/Connection/Http2Connection.php @@ -14,6 +14,7 @@ use Amp\Emitter; use Amp\Failure; use Amp\Http\Client\Connection\Internal\Http2Stream; +use Amp\Http\Client\HarAttributes; use Amp\Http\Client\HttpException; use Amp\Http\Client\Internal\ForbidCloning; use Amp\Http\Client\Internal\ForbidSerialization; @@ -35,6 +36,7 @@ use League\Uri; use function Amp\asyncCall; use function Amp\call; +use function Amp\getCurrentTime; final class Http2Connection implements Connection { @@ -168,6 +170,15 @@ final class Http2Connection implements Connection /** @var Deferred|null */ private $pongDeferred; + /** @var string|null */ + private $idleWatcher; + + /** @var int */ + private $idlePings = 0; + + /** @var int */ + private $requestCount = 0; + public function __construct(EncryptableSocket $socket) { $this->table = new HPack; @@ -218,11 +229,7 @@ public function getStream(Request $request): Promise throw new \Error('The promise returned from ' . __CLASS__ . '::initialize() must resolve before using the connection'); } - return call(function () use ($request) { - if ($this->streamId > 0 && empty($this->streams) && !yield $this->ping()) { - return null; - } - + return call(function () { if ($this->remainingStreams <= 0 || $this->onClose === null) { return null; } @@ -237,28 +244,6 @@ public function getStream(Request $request): Promise }); } - /** - * @return Promise Fulfilled with true if a pong is received within the timeout, false if none is received. - */ - private function ping(): Promise - { - if ($this->onClose === null) { - return new Success(false); - } - - if ($this->pongDeferred !== null) { - return $this->pongDeferred->promise(); - } - - $this->pongDeferred = new Deferred; - - $this->writeFrame($this->counter++, self::PING, self::NOFLAG); - - $this->pongWatcher = Loop::delay(self::PONG_TIMEOUT, [$this, 'close']); - - return $this->pongDeferred->promise(); - } - public function onClose(callable $onClose): void { if ($this->onClose === null) { @@ -289,8 +274,36 @@ public function getTlsInfo(): ?TlsInfo return $this->socket instanceof EncryptableSocket ? $this->socket->getTlsInfo() : null; } + /** + * @return Promise Fulfilled with true if a pong is received within the timeout, false if none is received. + */ + private function ping(): Promise + { + if ($this->onClose === null) { + return new Success(false); + } + + if ($this->pongDeferred !== null) { + return $this->pongDeferred->promise(); + } + + $this->pongDeferred = new Deferred; + $this->idlePings++; + + $this->writeFrame($this->counter++, self::PING, self::NOFLAG); + + $this->pongWatcher = Loop::delay(self::PONG_TIMEOUT, [$this, 'close']); + + return $this->pongDeferred->promise(); + } + private function request(Request $request, CancellationToken $token): Promise { + $this->requestCount++; + + $this->idlePings = 0; + $this->cancelIdleWatcher(); + // Remove defunct HTTP/1.x headers. $request->removeHeader('host'); $request->removeHeader('connection'); @@ -374,6 +387,8 @@ private function request(Request $request, CancellationToken $token): Promise ":method" => [$request->getMethod()], ], $request->getHeaders()); + $request->setAttribute(HarAttributes::TIME_SEND, getCurrentTime()); + $headers = $this->table->encode($headers); $stream = $body->createBodyStream(); @@ -381,6 +396,8 @@ private function request(Request $request, CancellationToken $token): Promise $chunk = yield $stream->read(); if (!isset($this->streams[$id]) || $token->isRequested()) { + $request->setAttribute(HarAttributes::TIME_WAIT, getCurrentTime()); + return yield $deferred->promise(); } @@ -401,12 +418,16 @@ private function request(Request $request, CancellationToken $token): Promise } if ($chunk === null) { + $request->setAttribute(HarAttributes::TIME_WAIT, getCurrentTime()); + return yield $deferred->promise(); } $buffer = $chunk; while (null !== $chunk = yield $stream->read()) { if (!isset($this->streams[$id]) || $token->isRequested()) { + $request->setAttribute(HarAttributes::TIME_WAIT, getCurrentTime()); + return yield $deferred->promise(); } @@ -415,6 +436,8 @@ private function request(Request $request, CancellationToken $token): Promise } if (!isset($this->streams[$id]) || $token->isRequested()) { + $request->setAttribute(HarAttributes::TIME_WAIT, getCurrentTime()); + return yield $deferred->promise(); } @@ -422,11 +445,18 @@ private function request(Request $request, CancellationToken $token): Promise yield $this->writeData($buffer, $id); + $request->setAttribute(HarAttributes::TIME_WAIT, getCurrentTime()); + return yield $deferred->promise(); } catch (\Throwable $exception) { if (isset($this->streams[$id])) { $this->releaseStream($id, $exception); } + + if ($exception instanceof StreamException) { + $exception = new HttpException('Failed to write request: ' . $exception->getMessage()); + } + throw $exception; } finally { $token->unsubscribe($cancellationId); @@ -750,6 +780,10 @@ private function parser(): \Generator unset($this->bodyEmitters[$id], $this->trailerDeferreds[$id]); + $stream->request->setAttribute(HarAttributes::TIME_COMPLETE, getCurrentTime()); + + $this->setupPingIfIdle(); + $emitter->complete(); $deferred->resolve(new Trailers([])); @@ -1342,6 +1376,10 @@ private function parser(): \Generator unset($this->bodyEmitters[$id], $this->trailerDeferreds[$id]); + $stream->request->setAttribute(HarAttributes::TIME_COMPLETE, getCurrentTime()); + + $this->setupPingIfIdle(); + $emitter->complete(); $deferred->resolve($headers); @@ -1374,6 +1412,8 @@ private function parser(): \Generator $deferred = $this->pendingRequests[$id]; unset($this->pendingRequests[$id]); + $stream->request->setAttribute(HarAttributes::TIME_RECEIVE, getCurrentTime()); + if ($stream->state & Http2Stream::REMOTE_CLOSED) { $response = new Response( '2', @@ -1508,6 +1548,8 @@ private function shutdown(?int $lastId = null, ?\Throwable $reason = null): Prom Loop::cancel($this->pongWatcher); } + $this->cancelIdleWatcher(); + if ($this->onClose !== null) { $onClose = $this->onClose; $this->onClose = null; @@ -1600,4 +1642,50 @@ private function sendBufferedData(): void $this->writeBufferedData($id); } } + + private function setupPingIfIdle(): void + { + if ($this->idleWatcher !== null) { + return; + } + + $this->idleWatcher = Loop::defer(function ($watcher) { + \assert($this->idleWatcher === null || $this->idleWatcher === $watcher); + + $this->idleWatcher = null; + if (!empty($this->streams)) { + return; + } + + $this->idleWatcher = Loop::delay(60000, function ($watcher) { + \assert($this->idleWatcher === null || $this->idleWatcher === $watcher); + \assert(empty($this->streams)); + + $this->idleWatcher = null; + + $maxIdleMinutes = $this->requestCount < 2 ? 1 : 3; + + if ($this->idlePings + 1 >= $maxIdleMinutes) { + $this->close(); + return; + } + + if (yield $this->ping()) { + $this->setupPingIfIdle(); + } + }); + + Loop::unreference($this->idleWatcher); + }); + + Loop::unreference($this->idleWatcher); + } + + private function cancelIdleWatcher(): void + { + if ($this->idleWatcher !== null) { + Loop::cancel($this->idleWatcher); + $this->idleWatcher = null; + } + } } diff --git a/src/Connection/UnlimitedConnectionPool.php b/src/Connection/UnlimitedConnectionPool.php index 5d42e400..cbf89aec 100644 --- a/src/Connection/UnlimitedConnectionPool.php +++ b/src/Connection/UnlimitedConnectionPool.php @@ -7,6 +7,7 @@ use Amp\CancelledException; use Amp\CombinedCancellationToken; use Amp\Coroutine; +use Amp\Http\Client\HarAttributes; use Amp\Http\Client\Internal\ForbidSerialization; use Amp\Http\Client\InvalidRequestException; use Amp\Http\Client\Request; @@ -21,6 +22,7 @@ use Amp\Success; use Amp\TimeoutCancellationToken; use function Amp\call; +use function Amp\getCurrentTime; final class UnlimitedConnectionPool implements ConnectionPool { @@ -144,9 +146,13 @@ public function getStream(Request $request, CancellationToken $cancellation): Pr continue; // No stream available for the given request. } + $request->setAttribute(HarAttributes::TIME_CONNECT, getCurrentTime()); + return $stream; } + $request->setAttribute(HarAttributes::TIME_CONNECT, getCurrentTime()); + $promise = new Coroutine($this->createConnection($request, $cancellation, $authority, $isHttps)); $hash = \spl_object_hash($promise); @@ -244,6 +250,8 @@ private function createConnection( } try { + $request->setAttribute(HarAttributes::TIME_SSL, getCurrentTime()); + $tlsState = $socket->getTlsState(); if ($tlsState === EncryptableSocket::TLS_STATE_DISABLED) { $tlsCancellationToken = new CombinedCancellationToken( diff --git a/src/HarAttributes.php b/src/HarAttributes.php new file mode 100644 index 00000000..d925d9f7 --- /dev/null +++ b/src/HarAttributes.php @@ -0,0 +1,17 @@ +intercept(new SetRequestHeaderIfUnset('accept', '*/*')); $client = $client->intercept(new SetRequestHeaderIfUnset('user-agent', 'amphp/http-client @ v4.x')); $client = $client->intercept(new DecompressResponse); + $client = $client->intercept(new RecordServerIp); foreach (\array_reverse($this->applicationInterceptors) as $interceptor) { $client = new InterceptedHttpClient($client, $interceptor); @@ -76,7 +79,7 @@ public function build(): HttpClient $client = new InterceptedHttpClient($client, $this->forbidUriUserInfo); } - return $client; + return new InterceptedHttpClient($client, new RecordStartTime); } /** diff --git a/src/Interceptor/LogIntoHttpArchive.php b/src/Interceptor/LogIntoHttpArchive.php new file mode 100644 index 00000000..80d48f2f --- /dev/null +++ b/src/Interceptor/LogIntoHttpArchive.php @@ -0,0 +1,204 @@ +hasAttribute($start) || !$request->hasAttribute($end)) { + return -1; + } + + return $request->getAttribute($end) - $request->getAttribute($start); + } + + private static function formatHeaders(Message $message): array + { + $headers = []; + + foreach ($message->getHeaders() as $field => $values) { + foreach ($values as $value) { + $headers[] = [ + 'name' => $field, + 'value' => $value, + ]; + } + } + + return $headers; + } + + private static function formatEntry(Response $response): array + { + $request = $response->getRequest(); + + $data = [ + 'startedDateTime' => $request->getAttribute(HarAttributes::STARTED_DATE_TIME)->format(\DateTimeInterface::RFC3339_EXTENDED), + 'time' => self::getTime($request, HarAttributes::TIME_START, HarAttributes::TIME_COMPLETE), + 'request' => [ + 'method' => $request->getMethod(), + 'url' => (string) $request->getUri()->withUserInfo(''), + 'httpVersion' => 'http/' . $request->getProtocolVersions()[0], + 'headers' => self::formatHeaders($request), + 'queryString' => [], + 'cookies' => [], + 'headersSize' => -1, + 'bodySize' => -1, + ], + 'response' => [ + 'status' => $response->getStatus(), + 'statusText' => $response->getReason(), + 'httpVersion' => 'http/' . $response->getProtocolVersion(), + 'headers' => self::formatHeaders($response), + 'cookies' => [], + 'redirectURL' => $response->getHeader('location') ?? '', + 'headersSize' => -1, + 'bodySize' => -1, + 'content' => [ + 'size' => (int) ($response->getHeader('content-length') ?? '-1'), + 'mimeType' => $response->getHeader('content-type') ?? '', + ], + ], + 'cache' => [], + 'timings' => [ + 'blocked' => self::getTime( + $request, + HarAttributes::TIME_START, + HarAttributes::TIME_CONNECT + ), + 'dns' => -1, + 'connect' => self::getTime( + $request, + HarAttributes::TIME_CONNECT, + HarAttributes::TIME_SEND + ), + 'ssl' => self::getTime( + $request, + HarAttributes::TIME_SSL, + HarAttributes::TIME_SEND + ), + 'send' => self::getTime( + $request, + HarAttributes::TIME_SEND, + HarAttributes::TIME_WAIT + ), + 'wait' => self::getTime( + $request, + HarAttributes::TIME_WAIT, + HarAttributes::TIME_RECEIVE + ), + 'receive' => self::getTime( + $request, + HarAttributes::TIME_RECEIVE, + HarAttributes::TIME_COMPLETE + ), + ], + ]; + + if ($request->hasAttribute(HarAttributes::SERVER_IP_ADDRESS)) { + $data['serverIPAddress'] = $request->getAttribute(HarAttributes::SERVER_IP_ADDRESS); + } + + return $data; + } + + /** @var LocalMutex */ + private $fileMutex; + /** @var File\File|null */ + private $fileHandle; + /** @var string */ + private $filePath; + /** @var \Throwable|null */ + private $error; + + public function __construct(string $filePath) + { + $this->filePath = $filePath; + $this->fileMutex = new LocalMutex; + } + + public function request(Request $request, CancellationToken $cancellation, DelegateHttpClient $next): Promise + { + return call(function () use ($request, $cancellation, $next) { + if ($this->error) { + throw $this->error; + } + + /** @var Response $response */ + $response = yield $next->request($request, $cancellation); + + rethrow($this->writeLog($response)); + + return $response; + }); + } + + public function reset(): Promise + { + return call(function () { + /** @var Lock $lock */ + $lock = yield $this->fileMutex->acquire(); + + // Will automatically reopen and reset the file + $this->fileHandle = null; + $this->error = null; + + $lock->release(); + }); + } + + private function writeLog(Response $response): Promise + { + return call(function () use ($response) { + try { + yield $response->getTrailers(); + } catch (\Throwable $e) { + // ignore, still log the remaining response times + } + + try { + /** @var Lock $lock */ + $lock = yield $this->fileMutex->acquire(); + + $firstEntry = $this->fileHandle === null; + + if ($firstEntry) { + $this->fileHandle = yield File\open($this->filePath, 'w'); + + $header = '{"log":{"version":"1.2","creator":{"name":"amphp/http-client","version":"4.x"},"pages":[],"entries":['; + + yield $this->fileHandle->write($header); + } else { + yield $this->fileHandle->seek(-3, \SEEK_CUR); + } + + /** @noinspection PhpComposerExtensionStubsInspection */ + $json = \json_encode(self::formatEntry($response)); + + yield $this->fileHandle->write(($firstEntry ? '' : ',') . $json . ']}}'); + + $lock->release(); + } catch (HttpException $e) { + $this->error = $e; + } catch (\Throwable $e) { + $this->error = new HttpException('Writing HTTP archive log failed', 0, $e); + } + }); + } +} diff --git a/src/Interceptor/RecordServerIp.php b/src/Interceptor/RecordServerIp.php new file mode 100644 index 00000000..870fedd9 --- /dev/null +++ b/src/Interceptor/RecordServerIp.php @@ -0,0 +1,25 @@ +getRemoteAddress()->getHost(); + if (\strrpos($host, ':')) { + $host = '[' . $host . ']'; + } + + $request->setAttribute(HarAttributes::SERVER_IP_ADDRESS, $host); + + return $stream->request($request, $cancellation); + } +} diff --git a/src/Interceptor/RecordStartTime.php b/src/Interceptor/RecordStartTime.php new file mode 100644 index 00000000..1774e257 --- /dev/null +++ b/src/Interceptor/RecordStartTime.php @@ -0,0 +1,22 @@ +setAttribute(HarAttributes::STARTED_DATE_TIME, new \DateTimeImmutable); + $request->setAttribute(HarAttributes::TIME_START, getCurrentTime()); + + return $next->request($request, $cancellation); + } +} diff --git a/test/Interceptor/InterceptorTest.php b/test/Interceptor/InterceptorTest.php index 6a232f6a..57455e1c 100644 --- a/test/Interceptor/InterceptorTest.php +++ b/test/Interceptor/InterceptorTest.php @@ -73,6 +73,7 @@ final protected function whenRequestIsExecuted(?ClientRequest $request = null): $this->response = $response; yield $this->response->getBody()->buffer(); + yield $this->response->getTrailers(); yield $this->server->stop(); diff --git a/test/Interceptor/LogIntoHttpArchiveTest.php b/test/Interceptor/LogIntoHttpArchiveTest.php new file mode 100644 index 00000000..87835ce7 --- /dev/null +++ b/test/Interceptor/LogIntoHttpArchiveTest.php @@ -0,0 +1,23 @@ +givenApplicationInterceptor($logger); + + yield $this->whenRequestIsExecuted(new Request('http://example.com/foo/bar?test=1')); + yield $logger->reset(); // awaits write because of the mutex + + $jsonLog = \file_get_contents($filePath); + + $this->assertJson($jsonLog); + } +}