From fdbdf2a6e609640ae39aeb36628b88bfe6511385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Sat, 26 Sep 2026 23:35:04 +0200 Subject: [PATCH] feat(symfony): add an Elastically panel to the Symfony profiler --- CHANGELOG.md | 1 + README.md | 13 +- composer.json | 5 +- .../ElasticallyDataCollector.php | 253 ++++++++++++++++++ .../Symfony/DataCollector/TraceableClient.php | 102 +++++++ .../Compiler/DataCollectorPass.php | 52 ++++ .../ElasticallyExtension.php | 1 + src/Bridge/Symfony/ElasticallyBundle.php | 10 + .../views/Collector/elastically.html.twig | 208 ++++++++++++++ .../ElasticallyDataCollectorTest.php | 145 ++++++++++ .../ElasticallyExtensionTest.php | 55 ++++ tests/Symfony/ProfilerTest.php | 53 ++++ tests/Symfony/ProfilerTestKernel.php | 55 ++++ tests/Symfony/TestController.php | 17 ++ 14 files changed, 967 insertions(+), 3 deletions(-) create mode 100644 src/Bridge/Symfony/DataCollector/ElasticallyDataCollector.php create mode 100644 src/Bridge/Symfony/DataCollector/TraceableClient.php create mode 100644 src/Bridge/Symfony/DependencyInjection/Compiler/DataCollectorPass.php create mode 100644 src/Bridge/Symfony/Resources/views/Collector/elastically.html.twig create mode 100644 tests/Bridge/Symfony/DataCollector/ElasticallyDataCollectorTest.php create mode 100644 tests/Symfony/ProfilerTest.php create mode 100644 tests/Symfony/ProfilerTestKernel.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e67ac2b..8b182f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow glob patterns in the mapping directory to spread mappings across several directories - Flush the `IndexationRequestSpoolSubscriber` spool after each message handled by a Messenger worker - Log an error when an Indexer queue was not flushed at the end of a request, a command or a Messenger message (Symfony bundle) +- Add an "Elastically" panel to the Symfony profiler, listing the requests sent to Elasticsearch (Symfony bundle) ### Changed diff --git a/README.md b/README.md index d4151bc..1a6bfc5 100644 --- a/README.md +++ b/README.md @@ -535,6 +535,17 @@ services: The spool is flushed at the end of each HTTP request, console command, and message handled by a Messenger worker (`messenger:consume`). When a worker fails to handle a message, the `IndexationRequest` queued meanwhile are discarded. +### Profiler + +When the Symfony profiler is enabled, Elastically adds an "Elastically" panel to it, and a counter to the web debug toolbar. For each connection, it lists the requests sent to Elasticsearch with: + +- their duration and status code; +- the request body and the response, readable; +- a "Copy as cURL" button (credentials are not included) and a "Copy for Kibana Dev Tools" button; +- a warning when the exact same request (method, URL and body) was sent more than once. + +There is nothing to configure: the panel is registered only when the `profiler` service exists, usually in the `dev` environment with the WebProfilerBundle installed. + ## Using Jane to build PHP DTO and fast Normalizers Install [JanePHP](https://jane.readthedocs.io/) json-schema tools to build your own DTO and Normalizers. All you have to do is setting the Jane-completed Serializer on the Factory: @@ -553,8 +564,6 @@ $factory = new Factory([ - some "todo" in the code - optional Doctrine connector - extra commands to monitor, update mapping, reindex... Commonly implemented tasks -- optional Symfony integration: - - web debug toolbar! - scripts / commands for common tasks: - auto-reindex when the mapping change, handle the aliases and everything - micro monitoring for cluster / indexes diff --git a/composer.json b/composer.json index 1a9a21a..1ed9e28 100644 --- a/composer.json +++ b/composer.json @@ -32,11 +32,14 @@ "jane-php/json-schema-runtime": "^7.4", "phpstan/phpstan": "^2.1.31", "symfony/browser-kit": "^6.4 || ^7.4 || ^8.0", + "symfony/css-selector": "^6.4 || ^7.4 || ^8.0", "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", "symfony/http-client": "^6.4 || ^7.4 || ^8.0", "symfony/http-foundation": "^6.4 || ^7.4 || ^8.0", "symfony/messenger": "^6.4 || ^7.4 || ^8.0", - "symfony/phpunit-bridge": "^6.4.1 || ^7.4 || ^8.0" + "symfony/phpunit-bridge": "^6.4.1 || ^7.4 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.4 || ^8.0" }, "suggest": { "symfony/json-streamer": "Speeds up the serialization process" diff --git a/src/Bridge/Symfony/DataCollector/ElasticallyDataCollector.php b/src/Bridge/Symfony/DataCollector/ElasticallyDataCollector.php new file mode 100644 index 0000000..75eb88b --- /dev/null +++ b/src/Bridge/Symfony/DataCollector/ElasticallyDataCollector.php @@ -0,0 +1,253 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace JoliCode\Elastically\Bridge\Symfony\DataCollector; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; + +final class ElasticallyDataCollector extends DataCollector implements LateDataCollectorInterface +{ + /** @var array */ + private array $clients = []; + + public function addClient(string $connection, TraceableClient $client): void + { + $this->clients[$connection] = $client; + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + // Everything is collected in lateCollect(), to catch the requests sent after the response + } + + public function lateCollect(): void + { + $this->data = [ + 'connections' => [], + 'request_count' => 0, + 'error_count' => 0, + 'duplicate_count' => 0, + 'duration' => 0.0, + ]; + + foreach ($this->clients as $connection => $client) { + $traces = $client->getTraces(); + + $occurrences = []; + foreach ($traces as $trace) { + $key = $this->getDuplicateKey($trace); + $occurrences[$key] = ($occurrences[$key] ?? 0) + 1; + } + + $requests = []; + foreach ($traces as $trace) { + $requests[] = $this->buildRequest($trace, $occurrences[$this->getDuplicateKey($trace)]); + } + + $hosts = $client->getConfig('hosts'); + + $this->data['connections'][$connection] = [ + 'hosts' => array_map($this->removeCredentials(...), \is_array($hosts) ? $hosts : [$hosts]), + 'requests' => $requests, + ]; + + $this->data['request_count'] += \count($requests); + foreach ($requests as $request) { + $this->data['duration'] += $request['duration']; + if ($request['is_error']) { + ++$this->data['error_count']; + } + } + foreach ($occurrences as $count) { + $this->data['duplicate_count'] += $count - 1; + } + } + } + + /** + * @return array, requests: list>}> + */ + public function getConnections(): array + { + return $this->data['connections'] ?? []; + } + + public function getRequestCount(): int + { + return $this->data['request_count'] ?? 0; + } + + public function getErrorCount(): int + { + return $this->data['error_count'] ?? 0; + } + + public function getDuplicateCount(): int + { + return $this->data['duplicate_count'] ?? 0; + } + + /** + * In milliseconds. + */ + public function getDuration(): float + { + return $this->data['duration'] ?? 0.0; + } + + public function getName(): string + { + return 'elastically'; + } + + public function reset(): void + { + $this->data = []; + + foreach ($this->clients as $client) { + $client->reset(); + } + } + + /** + * @param array{method: string, url: string, request_body: string, status_code: int|null, response_body: string|null, duration: float, error: string|null} $trace + * + * @return array + */ + private function buildRequest(array $trace, int $occurrences): array + { + $path = parse_url($trace['url'], \PHP_URL_PATH) ?: '/'; + if ($query = parse_url($trace['url'], \PHP_URL_QUERY)) { + $path .= '?' . $query; + } + + $isNdjson = false; + $requestBody = $this->decodeBody($trace['request_body'], $isNdjson); + + return [ + 'method' => $trace['method'], + 'url' => $trace['url'], + 'path' => $path, + 'status_code' => $trace['status_code'], + 'duration' => $trace['duration'], + 'error' => $trace['error'], + 'is_error' => $this->isError($trace), + 'occurrences' => $occurrences, + 'request_body' => null === $requestBody ? null : $this->cloneVar($requestBody), + 'response_body' => '' === trim($trace['response_body'] ?? '') ? null : $this->cloneVar($this->decodeBody($trace['response_body'])), + 'curl_command' => $this->buildCurlCommand($trace, $isNdjson), + 'console_command' => $this->buildConsoleCommand($trace['method'], $path, $trace['request_body'], $isNdjson), + ]; + } + + /** + * @param array{method: string, status_code: int|null, error: string|null} $trace + */ + private function isError(array $trace): bool + { + if (null !== $trace['error']) { + return true; + } + + // A 404 is the expected answer of HEAD requests on a missing resource, like Index::exists() + if ('HEAD' === $trace['method'] && 404 === $trace['status_code']) { + return false; + } + + return ($trace['status_code'] ?? 0) >= 400; + } + + private function decodeBody(string $body, bool &$isNdjson = false): mixed + { + $isNdjson = false; + + if ('' === trim($body)) { + return null; + } + + try { + return json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + } + + // Bulk requests use NDJSON + $lines = []; + foreach (explode("\n", trim($body)) as $line) { + try { + $lines[] = json_decode($line, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $body; + } + } + + $isNdjson = true; + + return $lines; + } + + /** + * @param array{method: string, url: string, request_body: string} $trace + */ + private function buildCurlCommand(array $trace, bool $isNdjson): string + { + $command = \sprintf('curl -X %s %s', $trace['method'], escapeshellarg($trace['url'])); + + if ('' !== trim($trace['request_body'])) { + $command .= \sprintf( + ' -H %s --data-binary %s', + escapeshellarg('Content-Type: ' . ($isNdjson ? 'application/x-ndjson' : 'application/json')), + escapeshellarg($isNdjson ? rtrim($trace['request_body'], "\n") . "\n" : $trace['request_body']), + ); + } + + return $command; + } + + /** + * Builds a request that can be pasted in the Kibana Dev Tools console. + */ + private function buildConsoleCommand(string $method, string $path, string $body, bool $isNdjson): string + { + $command = $method . ' ' . $path; + + if ('' === trim($body)) { + return $command; + } + + if ($isNdjson) { + return $command . "\n" . trim($body); + } + + try { + // Decoded as objects, so that empty objects are kept as "{}" + $body = json_encode(json_decode($body, false, 512, \JSON_THROW_ON_ERROR), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRESERVE_ZERO_FRACTION | \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + } + + return $command . "\n" . $body; + } + + /** + * @param array{method: string, url: string, request_body: string} $trace + */ + private function getDuplicateKey(array $trace): string + { + return $trace['method'] . ' ' . $trace['url'] . "\n" . $trace['request_body']; + } + + private function removeCredentials(mixed $host): string + { + return preg_replace('{//[^/@]*@}', '//', (string) $host) ?? ''; + } +} diff --git a/src/Bridge/Symfony/DataCollector/TraceableClient.php b/src/Bridge/Symfony/DataCollector/TraceableClient.php new file mode 100644 index 0000000..77bde80 --- /dev/null +++ b/src/Bridge/Symfony/DataCollector/TraceableClient.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace JoliCode\Elastically\Bridge\Symfony\DataCollector; + +use Elastic\Elasticsearch\Response\Elasticsearch; +use JoliCode\Elastically\Client; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Contracts\Service\ResetInterface; + +/** + * Records every request sent to Elasticsearch, for the Symfony profiler. + * + * @phpstan-type Trace array{ + * method: string, + * url: string, + * request_body: string, + * status_code: int|null, + * response_body: string|null, + * duration: float, + * error: string|null, + * } + */ +final class TraceableClient extends Client implements ResetInterface +{ + /** @var list */ + private array $traces = []; + private ?Stopwatch $stopwatch = null; + + public function setStopwatch(?Stopwatch $stopwatch): void + { + $this->stopwatch = $stopwatch; + } + + public function sendRequest(RequestInterface $request): Elasticsearch + { + $event = $this->stopwatch?->start(\sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath()), 'elastically'); + $start = microtime(true); + + try { + $response = parent::sendRequest($request); + } catch (\Throwable $e) { // @phpstan-ignore catch.neverThrown (the parent method does not document its exceptions) + $event?->stop(); + $this->addTrace($request, $start, $e); + + throw $e; + } + + $event?->stop(); + $this->addTrace($request, $start, $response); + + return $response; + } + + /** + * @return list + */ + public function getTraces(): array + { + return $this->traces; + } + + public function reset(): void + { + $this->traces = []; + } + + private function addTrace(RequestInterface $request, float $start, ResponseInterface|\Throwable $result): void + { + $response = $result instanceof ResponseInterface ? $result : null; + if ($result instanceof \Throwable && method_exists($result, 'getResponse') && ($exceptionResponse = $result->getResponse()) instanceof ResponseInterface) { + $response = $exceptionResponse; + } + + // The host is only known once the transport picked a node + $uri = $request->getUri(); + $sentRequest = $this->getTransport()->getLastRequest(); + if ($sentRequest && $sentRequest->getUri()->getPath() === $uri->getPath()) { + $uri = $sentRequest->getUri(); + } + + $this->traces[] = [ + 'method' => $request->getMethod(), + 'url' => (string) $uri->withUserInfo(''), + 'request_body' => (string) $request->getBody(), + 'status_code' => $response?->getStatusCode(), + 'response_body' => $response ? (string) $response->getBody() : null, + 'duration' => (microtime(true) - $start) * 1000, + 'error' => $result instanceof \Throwable ? $result->getMessage() : null, + ]; + } +} diff --git a/src/Bridge/Symfony/DependencyInjection/Compiler/DataCollectorPass.php b/src/Bridge/Symfony/DependencyInjection/Compiler/DataCollectorPass.php new file mode 100644 index 0000000..3af1874 --- /dev/null +++ b/src/Bridge/Symfony/DependencyInjection/Compiler/DataCollectorPass.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace JoliCode\Elastically\Bridge\Symfony\DependencyInjection\Compiler; + +use JoliCode\Elastically\Bridge\Symfony\DataCollector\ElasticallyDataCollector; +use JoliCode\Elastically\Bridge\Symfony\DataCollector\TraceableClient; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Traces the requests of every connection when the Symfony profiler is enabled. + */ +class DataCollectorPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + // Always read the tag, so it is not reported as unused + $clients = $container->findTaggedServiceIds('elastically.client'); + + if (!$container->hasDefinition('profiler')) { + return; + } + + $collector = $container->register('elastically.data_collector', ElasticallyDataCollector::class) + ->addTag('data_collector', [ + 'template' => '@Elastically/Collector/elastically.html.twig', + 'id' => 'elastically', + 'priority' => 250, + ]) + ; + + foreach ($clients as $id => $tags) { + $container->getDefinition($id) + ->setClass(TraceableClient::class) + ->addMethodCall('setStopwatch', [new Reference('debug.stopwatch', ContainerInterface::NULL_ON_INVALID_REFERENCE)]) + ; + + $collector->addMethodCall('addClient', [$tags[0]['connection'], new Reference($id)]); + } + } +} diff --git a/src/Bridge/Symfony/DependencyInjection/ElasticallyExtension.php b/src/Bridge/Symfony/DependencyInjection/ElasticallyExtension.php index c2b7e7f..e6ac86d 100644 --- a/src/Bridge/Symfony/DependencyInjection/ElasticallyExtension.php +++ b/src/Bridge/Symfony/DependencyInjection/ElasticallyExtension.php @@ -83,6 +83,7 @@ private function buildConnection(string $name, array $config, bool $isDefaultCon $client->replaceArgument('$config', $config['client'] ?? []); $client->replaceArgument('$resultSetBuilder', new Reference("elastically.{$name}.result_set_builder")); $client->replaceArgument('$indexNameMapper', new Reference("elastically.{$name}.index_name_mapper")); + $client->addTag('elastically.client', ['connection' => $name]); $container->setDefinition($id = "elastically.{$name}.client", $client); if ($isDefaultConnection) { $container->setAlias(Client::class, $id); diff --git a/src/Bridge/Symfony/ElasticallyBundle.php b/src/Bridge/Symfony/ElasticallyBundle.php index ddb48bc..6e34ecb 100644 --- a/src/Bridge/Symfony/ElasticallyBundle.php +++ b/src/Bridge/Symfony/ElasticallyBundle.php @@ -11,8 +11,18 @@ namespace JoliCode\Elastically\Bridge\Symfony; +use JoliCode\Elastically\Bridge\Symfony\DependencyInjection\Compiler\DataCollectorPass; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class ElasticallyBundle extends Bundle { + public function build(ContainerBuilder $container): void + { + parent::build($container); + + // Must run before the ProfilerPass of the FrameworkBundle + $container->addCompilerPass(new DataCollectorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + } } diff --git a/src/Bridge/Symfony/Resources/views/Collector/elastically.html.twig b/src/Bridge/Symfony/Resources/views/Collector/elastically.html.twig new file mode 100644 index 0000000..a1fbd09 --- /dev/null +++ b/src/Bridge/Symfony/Resources/views/Collector/elastically.html.twig @@ -0,0 +1,208 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block head %} + {{ parent() }} + + +{% endblock %} + +{% block toolbar %} + {% if collector.requestCount %} + {% set status_color = collector.errorCount ? 'red' : (collector.duplicateCount ? 'yellow' : '') %} + + {% set icon %} + {{ source('@WebProfiler/Icon/search.svg') }} + {{ collector.requestCount }} + + in + {{ '%0.2f'|format(collector.duration) }} + ms + + {% endset %} + + {% set text %} +
+ Elasticsearch requests + {{ collector.requestCount }} +
+
+ Request time + {{ '%0.2f'|format(collector.duration) }} ms +
+
+ Errors + {{ collector.errorCount }} +
+
+ Duplicated requests + {{ collector.duplicateCount }} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', {link: profiler_url, status: status_color}) }} + {% endif %} +{% endblock %} + +{% block menu %} + + {{ source('@WebProfiler/Icon/search.svg') }} + Elastically + {% if collector.requestCount %} + {{ collector.requestCount }} + {% endif %} + +{% endblock %} + +{% block panel %} +

Elastically

+ + {% if collector.connections is empty %} +
+

No Elastically connection is configured.

+
+ {% else %} +
+
+ {{ collector.requestCount }} + Requests +
+
+ {{ '%0.2f'|format(collector.duration) }} ms + Request time +
+
+ {{ collector.errorCount }} + Errors +
+
+ {{ collector.duplicateCount }} + Duplicated requests +
+
+ + {% if collector.duplicateCount %} +

+ {{ collector.duplicateCount }} request(s) were sent more than once with the same method, URL and body. They could probably be avoided. +

+ {% endif %} + +

Connections

+ +
+ {% for name, connection in collector.connections %} +
+

{{ name }} {{ connection.requests|length }}

+ +
+

Hosts: {{ connection.hosts|join(', ') }}

+ + {% if connection.requests is empty %} +
+

No request was sent with the "{{ name }}" connection.

+
+ {% else %} + + + + + + + + + + + {% for request in connection.requests %} + {% set request_id = 'elastically-' ~ name ~ '-' ~ loop.index %} + + + + + + + {% endfor %} + +
#TimeStatusRequest
{{ loop.index }}{{ '%0.2f'|format(request.duration) }} ms + {% if request.status_code %} + {{ request.status_code }} + {% else %} + n/a + {% endif %} + + {{ request.method }} + {{ request.path }} + + {% if request.occurrences > 1 %} + Duplicated ×{{ request.occurrences }} + {% endif %} + + {% if request.error %} +
{{ request.error|length > 300 ? request.error|slice(0, 300) ~ '…' : request.error }}
+ {% endif %} + +
+ {% if request.request_body is not null %} + + {% endif %} + {% if request.response_body is not null %} + + {% endif %} + + +
+ + {% if request.request_body is not null %} +
+

Request body

+ {{ profiler_dump(request.request_body, maxDepth: 5) }} +
+ {% endif %} + {% if request.response_body is not null %} +
+

Response

+ {{ profiler_dump(request.response_body, maxDepth: 5) }} +
+ {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/tests/Bridge/Symfony/DataCollector/ElasticallyDataCollectorTest.php b/tests/Bridge/Symfony/DataCollector/ElasticallyDataCollectorTest.php new file mode 100644 index 0000000..a6ad695 --- /dev/null +++ b/tests/Bridge/Symfony/DataCollector/ElasticallyDataCollectorTest.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace JoliCode\Elastically\Tests\Bridge\Symfony\DataCollector; + +use Elastic\Elasticsearch\Exception\ClientResponseException; +use Elastica\Document; +use Elastica\Query; +use JoliCode\Elastically\Bridge\Symfony\DataCollector\ElasticallyDataCollector; +use JoliCode\Elastically\Bridge\Symfony\DataCollector\TraceableClient; +use JoliCode\Elastically\IndexNameMapper; +use JoliCode\Elastically\ResultSetBuilder; +use JoliCode\Elastically\Serializer\StaticContextBuilder; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Stopwatch\Stopwatch; + +class ElasticallyDataCollectorTest extends TestCase +{ + public function testCollect(): void + { + $search = '{"took":1,"timed_out":false,"hits":{"total":{"value":0,"relation":"eq"},"hits":[]}}'; + $client = $this->createClient([ + $this->createResponse($search), + $this->createResponse($search), + $this->createResponse('{"took":1,"errors":false,"items":[]}'), + $this->createResponse('{"error":{"type":"index_not_found_exception"},"status":404}', 404), + ]); + + $client->getIndex('beers')->search(new Query(new Query\MatchAll())); + $client->getIndex('beers')->search(new Query(new Query\MatchAll())); + $client->addDocuments([new Document('1', ['name' => 'Kwak'], 'beers'), new Document('2', ['name' => 'Chimay'], 'beers')]); + + try { + $client->getIndex('missing')->search(new Query(new Query\MatchAll())); + $this->fail('An exception should have been thrown.'); + } catch (ClientResponseException) { + } + + $collector = new ElasticallyDataCollector(); + $collector->addClient('default', $client); + $collector->lateCollect(); + + $this->assertSame(4, $collector->getRequestCount()); + $this->assertSame(1, $collector->getErrorCount()); + $this->assertSame(1, $collector->getDuplicateCount()); + $this->assertGreaterThan(0, $collector->getDuration()); + + $connection = $collector->getConnections()['default']; + $this->assertSame(['http://localhost:9200'], $connection['hosts']); + $this->assertCount(4, $connection['requests']); + + [$first, $second, $bulk, $error] = $connection['requests']; + + $this->assertSame('POST', $first['method']); + $this->assertSame('/beers/_search', $first['path']); + $this->assertSame(200, $first['status_code']); + $this->assertSame(2, $first['occurrences']); + $this->assertSame(2, $second['occurrences']); + $this->assertFalse($first['is_error']); + $this->assertSame(<<<'CONSOLE' + POST /beers/_search + { + "query": { + "match_all": {} + } + } + CONSOLE, $first['console_command']); + $this->assertSame("curl -X POST 'http://localhost:9200/beers/_search' -H 'Content-Type: application/json' --data-binary '{\"query\":{\"match_all\":{}}}'", $first['curl_command']); + + $this->assertSame('/_bulk', $bulk['path']); + $this->assertSame(1, $bulk['occurrences']); + $this->assertStringContainsString("-H 'Content-Type: application/x-ndjson'", $bulk['curl_command']); + $this->assertStringStartsWith("POST /_bulk\n{", $bulk['console_command']); + $this->assertCount(5, explode("\n", $bulk['console_command'])); + + $this->assertSame(404, $error['status_code']); + $this->assertTrue($error['is_error']); + $this->assertNotNull($error['error']); + $this->assertNotNull($error['response_body']); + + $collector->reset(); + + $this->assertSame([], $client->getTraces()); + $this->assertSame(0, $collector->getRequestCount()); + $this->assertSame([], $collector->getConnections()); + } + + public function testTraceableClientUsesTheStopwatch(): void + { + $client = $this->createClient([ + $this->createResponse('{"took":1,"timed_out":false,"hits":{"total":{"value":0,"relation":"eq"},"hits":[]}}'), + ]); + $client->setStopwatch($stopwatch = new Stopwatch()); + + $client->getIndex('beers')->search(new Query(new Query\MatchAll())); + + $this->assertArrayHasKey('POST /beers/_search', $stopwatch->getSectionEvents('__root__')); + $this->assertSame('elastically', $stopwatch->getEvent('POST /beers/_search')->getCategory()); + } + + /** + * @param list $responses + */ + private function createClient(array $responses): TraceableClient + { + $indexNameMapper = new IndexNameMapper(null, ['beers' => \stdClass::class, 'missing' => \stdClass::class]); + $serializer = new Serializer([new ObjectNormalizer()]); + + return new TraceableClient( + [ + 'hosts' => ['http://elastic:secret@localhost:9200'], + 'transport_config' => [ + 'http_client' => new Psr18Client(new MockHttpClient($responses)), + ], + ], + null, + new ResultSetBuilder($indexNameMapper, new StaticContextBuilder(), $serializer), + $indexNameMapper, + ); + } + + private function createResponse(string $body, int $status = 200): MockResponse + { + return new MockResponse($body, [ + 'http_code' => $status, + 'response_headers' => [ + 'Content-Type' => 'application/json', + 'X-Elastic-Product' => 'Elasticsearch', + ], + ]); + } +} diff --git a/tests/Bridge/Symfony/DependencyInjection/ElasticallyExtensionTest.php b/tests/Bridge/Symfony/DependencyInjection/ElasticallyExtensionTest.php index f00ba17..08389b3 100644 --- a/tests/Bridge/Symfony/DependencyInjection/ElasticallyExtensionTest.php +++ b/tests/Bridge/Symfony/DependencyInjection/ElasticallyExtensionTest.php @@ -11,6 +11,7 @@ namespace JoliCode\Elastically\Tests\Bridge\Symfony\DependencyInjection; +use JoliCode\Elastically\Bridge\Symfony\DataCollector\TraceableClient; use JoliCode\Elastically\Bridge\Symfony\DependencyInjection\ElasticallyExtension; use JoliCode\Elastically\Bridge\Symfony\ElasticallyBundle; use JoliCode\Elastically\Client; @@ -259,6 +260,60 @@ public function testWithTransport(): void $this->assertInstanceOf(Reference::class, $configArgument['transport_config']['http_client'] ?? null); } + public function testDataCollectorIsNotRegisteredWithoutProfiler(): void + { + $container = $this->buildContainer(); + + $container->loadFromExtension('elastically', [ + 'connections' => [ + 'default' => [ + 'mapping_directory' => __DIR__, + 'index_class_mapping' => ['foobar' => self::class], + ], + ], + ]); + + $container->compile(); + + $this->assertFalse($container->hasDefinition('elastically.data_collector')); + $this->assertNull($container->getDefinition('elastically.default.client')->getClass()); + } + + public function testDataCollectorIsRegisteredWithProfiler(): void + { + $container = $this->buildContainer(); + $container->register('profiler', \stdClass::class); + + $container->loadFromExtension('elastically', [ + 'connections' => [ + 'foobar' => [ + 'mapping_directory' => __DIR__, + 'index_class_mapping' => ['foobar' => self::class], + ], + 'another' => [ + 'mapping_directory' => __DIR__, + 'index_class_mapping' => ['foobar' => self::class], + ], + ], + ]); + + $container->compile(); + + $this->assertTrue($container->hasDefinition('elastically.data_collector')); + $collector = $container->getDefinition('elastically.data_collector'); + $this->assertSame('elastically', $collector->getTag('data_collector')[0]['id']); + + $clients = []; + foreach ($collector->getMethodCalls() as [$method, $arguments]) { + $this->assertSame('addClient', $method); + $clients[$arguments[0]] = (string) $arguments[1]; + } + $this->assertSame(['foobar' => 'elastically.foobar.client', 'another' => 'elastically.another.client'], $clients); + + $this->assertSame(TraceableClient::class, $container->getDefinition('elastically.foobar.client')->getClass()); + $this->assertSame(TraceableClient::class, $container->getDefinition('elastically.another.client')->getClass()); + } + private function buildContainer(): ContainerBuilder { $container = new ContainerBuilder(); diff --git a/tests/Symfony/ProfilerTest.php b/tests/Symfony/ProfilerTest.php new file mode 100644 index 0000000..cc4472c --- /dev/null +++ b/tests/Symfony/ProfilerTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace JoliCode\Elastically\Tests\Symfony; + +use JoliCode\Elastically\Bridge\Symfony\DataCollector\ElasticallyDataCollector; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +class ProfilerTest extends WebTestCase +{ + public function testRequestsAreCollected(): void + { + $client = self::createClient(); + $client->enableProfiler(); + $client->request('GET', '/with_search'); + + $this->assertResponseIsSuccessful(); + + $collector = $client->getProfile()->getCollector('elastically'); + $this->assertInstanceOf(ElasticallyDataCollector::class, $collector); + $this->assertSame(3, $collector->getRequestCount()); + $this->assertSame(1, $collector->getDuplicateCount()); + $this->assertSame(1, $collector->getErrorCount()); + $this->assertSame(['default', 'special'], array_keys($collector->getConnections())); + + $requests = $collector->getConnections()['default']['requests']; + $this->assertSame('HEAD', $requests[0]['method']); + $this->assertSame('/hop', $requests[0]['path']); + $this->assertSame('POST', $requests[2]['method']); + $this->assertSame('/hop/_search', $requests[2]['path']); + + $client->request('GET', \sprintf('/_profiler/%s?panel=elastically', $client->getProfile()->getToken())); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('#collector-content h2', 'Elastically'); + $this->assertSelectorCount(3, '.sf-profiler-elastically-requests tbody tr'); + $this->assertSelectorExists('[data-clipboard-text^="curl -X POST"]'); + $this->assertSelectorTextContains('.sf-profiler-elastically-requests', 'Duplicated ×2'); + } + + protected static function getKernelClass(): string + { + return ProfilerTestKernel::class; + } +} diff --git a/tests/Symfony/ProfilerTestKernel.php b/tests/Symfony/ProfilerTestKernel.php new file mode 100644 index 0000000..93b67e1 --- /dev/null +++ b/tests/Symfony/ProfilerTestKernel.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace JoliCode\Elastically\Tests\Symfony; + +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class ProfilerTestKernel extends TestKernel +{ + public function registerBundles(): iterable + { + yield from parent::registerBundles(); + yield new TwigBundle(); + yield new WebProfilerBundle(); + } + + public function getCacheDir(): string + { + return parent::getCacheDir() . '_profiler'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $c->loadFromExtension('framework', [ + 'profiler' => ['enabled' => true, 'collect' => true], + ]); + $c->loadFromExtension('web_profiler', [ + 'toolbar' => false, + ]); + } + + protected function configureRoutes($routes): void + { + parent::configureRoutes($routes); + + $routeConfigurator = $routes->add('with_search', '/with_search'); + $routeConfigurator->controller(\sprintf('%s::withSearch', TestController::class)); + + $resource = \dirname((new \ReflectionClass(WebProfilerBundle::class))->getFileName()) . '/Resources/config/routing/profiler'; + $routes->import(is_file($resource . '.php') ? $resource . '.php' : $resource . '.xml')->prefix('/_profiler'); + } +} diff --git a/tests/Symfony/TestController.php b/tests/Symfony/TestController.php index 9df53c4..23967b2 100644 --- a/tests/Symfony/TestController.php +++ b/tests/Symfony/TestController.php @@ -11,6 +11,8 @@ namespace JoliCode\Elastically\Tests\Symfony; +use Elastica\Query; +use JoliCode\Elastically\Client; use JoliCode\Elastically\Messenger\IndexationRequest; use JoliCode\Elastically\Tests\Messenger\TestDTO; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -34,4 +36,19 @@ public function withResponse(MessageBusInterface $bus) return new Response('Everything is fine.', Response::HTTP_OK); } + + public function withSearch(Client $defaultClient): Response + { + $index = $defaultClient->getIndex('hop'); + $index->exists(); + $index->exists(); + + try { + $index->search(new Query(new Query\MatchAll())); + } catch (\Throwable) { + // The index does not exist + } + + return new Response('Searched.', Response::HTTP_OK); + } }