diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
index 19386d9721ae9..2002f589bc90d 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
@@ -62,6 +62,7 @@
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\ScopingHttpClient;
+use Symfony\Component\HttpClient\TraceableHttpClient;
use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
@@ -1874,6 +1875,15 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
{
$loader->load('http_client.xml');
+ $collectorDefinition = $container->getDefinition('data_collector.http_client');
+
+ if (!$debug = $container->getParameter('kernel.debug')) {
+ $container->removeDefinition('debug.http_client');
+ $collectorDefinition
+ ->setMethodCalls([])
+ ->clearTag('data_collector');
+ }
+
$container->getDefinition('http_client')->setArguments([$config['default_options'] ?? [], $config['max_host_connections'] ?? 6]);
if (!$hasPsr18 = interface_exists(ClientInterface::class)) {
@@ -1898,6 +1908,19 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
->setArguments([new Reference('http_client'), [$scope => $scopeConfig], $scope]);
}
+ if ($debug) {
+ $innerId = '.'.$name.'.inner';
+
+ $container->setDefinition($innerId, $container->getDefinition($name)
+ ->replaceArgument(0, new Reference('debug.http_client.inner'))
+ );
+
+ $container->register($name, TraceableHttpClient::class)
+ ->setArguments([new Reference($innerId)]);
+
+ $collectorDefinition->addMethodCall('addClient', [$name, new Reference($name)]);
+ }
+
$container->registerAliasForArgument($name, HttpClientInterface::class);
if ($hasPsr18) {
diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml
index aa29944c472d3..e3094863ec517 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.xml
@@ -22,5 +22,17 @@
+
+
+
+
+ http_client
+
+
+
+
+
+
+
diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json
index de007cd0b5f98..9d8654e19e7c9 100644
--- a/src/Symfony/Bundle/FrameworkBundle/composer.json
+++ b/src/Symfony/Bundle/FrameworkBundle/composer.json
@@ -39,7 +39,7 @@
"symfony/polyfill-intl-icu": "~1.0",
"symfony/form": "^4.3|^5.0",
"symfony/expression-language": "^3.4|^4.0|^5.0",
- "symfony/http-client": "^4.3|^5.0",
+ "symfony/http-client": "^4.4|^5.0",
"symfony/mailer": "^4.3|^5.0",
"symfony/messenger": "^4.3|^5.0",
"symfony/mime": "^4.3|^5.0",
@@ -71,6 +71,7 @@
"symfony/console": "<4.3",
"symfony/dotenv": "<4.2",
"symfony/dom-crawler": "<4.3",
+ "symfony/http-client": "<4.4",
"symfony/form": "<4.3",
"symfony/messenger": "<4.3",
"symfony/property-info": "<3.4",
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/http_client.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/http_client.html.twig
new file mode 100644
index 0000000000000..ad847f12f22f5
--- /dev/null
+++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/http_client.html.twig
@@ -0,0 +1,98 @@
+{% extends '@WebProfiler/Profiler/layout.html.twig' %}
+
+{% block toolbar %}
+ {% if collector.requestCount %}
+ {% set icon %}
+ {{ include('@WebProfiler/Icon/http-client.svg') }}
+ {% set status_color = '' %}
+ {{ collector.requestCount }}
+ {% endset %}
+
+ {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }}
+ {% endif %}
+{% endblock %}
+
+{% block menu %}
+
+ {{ include('@WebProfiler/Icon/http-client.svg') }}
+ HTTP Client
+ {% if collector.requestCount %}
+
+ {{ collector.requestCount }}
+
+ {% endif %}
+
+{% endblock %}
+
+{% block panel %}
+
HTTP Client
+ {% if collector.requestCount == 0 %}
+
+
No HTTP requests were made.
+
+ {% else %}
+
+
+ {{ collector.requestCount }}
+ Total requests
+
+
+ {{ collector.errorCount }}
+ HTTP errors
+
+
+ Clients
+
+ {% for name, client in collector.clients %}
+
+
{{ name }} {{ client.traces|length }}
+
+ {% if client.traces|length == 0 %}
+
+
No requests were made with the "{{ name }}" service.
+
+ {% else %}
+
Requests
+ {% for trace in client.traces %}
+
+
+
+
+ {{ trace.method }}
+ |
+
+ {{ trace.url }}
+ {% if trace.options is not empty %}
+ {{ profiler_dump(trace.options, maxDepth=1) }}
+ {% endif %}
+ |
+
+
+
+
+
+ {% if trace.http_code >= 500 %}
+ {% set responseStatus = 'error' %}
+ {% elseif trace.http_code >= 400 %}
+ {% set responseStatus = 'warning' %}
+ {% else %}
+ {% set responseStatus = 'success' %}
+ {% endif %}
+
+ {{ trace.http_code }}
+
+ |
+
+ {{ profiler_dump(trace.info, maxDepth=1) }}
+ |
+
+
+
+ {% endfor %}
+ {% endif %}
+
+
+ {% endfor %}
+ {% endif %}
+
+{% endblock %}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/http-client.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/http-client.svg
new file mode 100644
index 0000000000000..e6b1fb2fe903c
--- /dev/null
+++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/http-client.svg
@@ -0,0 +1 @@
+
diff --git a/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php b/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php
new file mode 100644
index 0000000000000..a139756b8ed97
--- /dev/null
+++ b/src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php
@@ -0,0 +1,120 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\DataCollector;
+
+use Symfony\Component\HttpClient\TraceableHttpClient;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\DataCollector\DataCollector;
+
+/**
+ * @author Jérémy Romey
+ */
+final class HttpClientDataCollector extends DataCollector
+{
+ /**
+ * @var TraceableHttpClient[]
+ */
+ private $clients = [];
+
+ public function addClient(string $name, TraceableHttpClient $client)
+ {
+ $this->clients[$name] = $client;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function collect(Request $request, Response $response, \Exception $exception = null)
+ {
+ $this->data = [
+ 'clients' => [],
+ 'request_count' => 0,
+ 'error_count' => 0,
+ ];
+
+ foreach ($this->clients as $name => $client) {
+ $traces = $client->getTraces();
+
+ $this->data['request_count'] += \count($traces);
+ $errorCount = 0;
+
+ foreach ($traces as $i => $trace) {
+ if (400 <= ($trace['info']['http_code'] ?? 0)) {
+ ++$errorCount;
+ }
+
+ $info = $trace['info'];
+ $traces[$i]['http_code'] = $info['http_code'];
+
+ unset($info['filetime'], $info['http_code'], $info['ssl_verify_result'], $info['content_type']);
+
+ if ($trace['method'] === $info['http_method']) {
+ unset($info['http_method']);
+ }
+
+ if ($trace['url'] === $info['url']) {
+ unset($info['url']);
+ }
+
+ foreach ($info as $k => $v) {
+ if (!$v || (is_numeric($v) && 0 > $v)) {
+ unset($info[$k]);
+ }
+ }
+
+ $traces[$i]['info'] = $this->cloneVar($info);
+ $traces[$i]['options'] = $this->cloneVar($trace['options']);
+ }
+
+ $this->data['clients'][$name] = [
+ 'traces' => $traces,
+ 'error_count' => $errorCount,
+ ];
+ $this->data['error_count'] += $errorCount;
+ }
+ }
+
+ public function getClients(): array
+ {
+ return $this->data['clients'] ?? [];
+ }
+
+ public function getRequestCount(): int
+ {
+ return $this->data['request_count'] ?? 0;
+ }
+
+ public function getErrorCount(): int
+ {
+ return $this->data['error_count'] ?? 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function reset()
+ {
+ $this->data = [];
+ foreach ($this->clients as $client) {
+ $client->clearTraces();
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getName(): string
+ {
+ return 'http_client';
+ }
+}
diff --git a/src/Symfony/Component/HttpClient/TraceableHttpClient.php b/src/Symfony/Component/HttpClient/TraceableHttpClient.php
new file mode 100644
index 0000000000000..7d3bc04fb151d
--- /dev/null
+++ b/src/Symfony/Component/HttpClient/TraceableHttpClient.php
@@ -0,0 +1,73 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient;
+
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+use Symfony\Contracts\HttpClient\ResponseStreamInterface;
+
+/**
+ * @author Jérémy Romey
+ */
+final class TraceableHttpClient implements HttpClientInterface
+{
+ private $client;
+ private $traces = [];
+
+ public function __construct(HttpClientInterface $client)
+ {
+ $this->client = $client;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function request(string $method, string $url, array $options = []): ResponseInterface
+ {
+ $traceInfo = [];
+ $this->traces[] = [
+ 'method' => $method,
+ 'url' => $url,
+ 'options' => $options,
+ 'info' => &$traceInfo,
+ ];
+ $onProgress = $options['on_progress'] ?? null;
+
+ $options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use (&$traceInfo, $onProgress) {
+ $traceInfo = $info;
+
+ if ($onProgress) {
+ $onProgress($dlNow, $dlSize, $info);
+ }
+ };
+
+ return $this->client->request($method, $url, $options);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function stream($responses, float $timeout = null): ResponseStreamInterface
+ {
+ return $this->client->stream($responses, $timeout);
+ }
+
+ public function getTraces(): array
+ {
+ return $this->traces;
+ }
+
+ public function clearTraces()
+ {
+ $this->traces = [];
+ }
+}