From 0b566ccce27c137b09fffaf6879e83d6cce94559 Mon Sep 17 00:00:00 2001 From: Spamer Date: Fri, 17 Jul 2026 15:08:03 +0200 Subject: [PATCH 1/2] fix(panel): decode NDJSON bulk bodies without throwing Bulk API requests use NDJSON, which Nette Json::decode rejects as a syntax error. With the debug panel enabled this exception was thrown from the transport's PSR-3 logging before the request was sent, so every bulk call crashed. Decode NDJSON line by line and fall back to the raw string so diagnostics can never break the request itself. Co-Authored-By: Claude Fable 5 --- src/Diagnostics/PanelLogger.php | 37 ++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/Diagnostics/PanelLogger.php b/src/Diagnostics/PanelLogger.php index 06135480..f51b2f21 100644 --- a/src/Diagnostics/PanelLogger.php +++ b/src/Diagnostics/PanelLogger.php @@ -201,7 +201,7 @@ private function logQuery( 'uri' => $request->getUri()->getPath(), 'requestBody' => \Tracy\Dumper::toHtml( - \Nette\Utils\Json::decode($contents, \JSON_OBJECT_AS_ARRAY), + $this->decodeBody($contents), [ \Tracy\Dumper::DEPTH => 30, ], @@ -218,10 +218,9 @@ private function logQuery( if ($contents === '') { $contents = '{}'; } - $decoded = \Nette\Utils\Json::decode($contents, \JSON_OBJECT_AS_ARRAY); $query['responseBody'] = \Tracy\Dumper::toHtml( - $decoded, + $this->decodeBody($contents), [ \Tracy\Dumper::DEPTH => 30, ], @@ -236,4 +235,36 @@ private function logQuery( } + /** + * Bulk API bodies are NDJSON (one JSON document per line), not a single JSON document. + * Diagnostics must never break the request itself, so anything undecodable is dumped + * as the raw string instead of throwing. + */ + private function decodeBody( + string $contents, + ): mixed + { + try { + return \Nette\Utils\Json::decode($contents, \JSON_OBJECT_AS_ARRAY); + } catch (\Nette\Utils\JsonException) { + // Not a single JSON document, try NDJSON below. + } + + $documents = []; + foreach (\explode("\n", $contents) as $line) { + if (\trim($line) === '') { + continue; + } + + try { + $documents[] = \Nette\Utils\Json::decode($line, \JSON_OBJECT_AS_ARRAY); + } catch (\Nette\Utils\JsonException) { + return $contents; + } + } + + return $documents; + } + + } From 54b90a716981d3fde9a0d355ccf0c9af533e3c71 Mon Sep 17 00:00:00 2001 From: Spamer Date: Fri, 17 Jul 2026 15:08:03 +0200 Subject: [PATCH 2/2] test(panel): cover PanelLogger body decoding variants Cover single-document JSON, NDJSON bulk bodies, undecodable bodies and request/response pairing so the panel logger's decoding behaviour is locked in. Co-Authored-By: Claude Fable 5 --- .../Elastic/Diagnostics/PanelLoggerTest.phpt | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/SpameriTests/Elastic/Diagnostics/PanelLoggerTest.phpt diff --git a/tests/SpameriTests/Elastic/Diagnostics/PanelLoggerTest.phpt b/tests/SpameriTests/Elastic/Diagnostics/PanelLoggerTest.phpt new file mode 100644 index 00000000..e2b256c3 --- /dev/null +++ b/tests/SpameriTests/Elastic/Diagnostics/PanelLoggerTest.phpt @@ -0,0 +1,113 @@ +panelLogger = new \Spameri\Elastic\Diagnostics\PanelLogger( + new \Psr\Log\NullLogger(), + ); + } + + + public function testLogsSingleJsonDocumentRequest(): void + { + $this->panelLogger->info('Request: POST /index/_search', [ + 'request' => new \GuzzleHttp\Psr7\Request( + 'POST', + 'http://localhost:9200/index/_search', + [], + '{"query": {"match_all": {}}}', + ), + ]); + + $queries = $this->panelLogger->getQueries(); + + \Tester\Assert::count(1, $queries); + \Tester\Assert::same('/index/_search', $queries[0]['uri']); + \Tester\Assert::contains('match_all', $queries[0]['requestBody']); + } + + + public function testLogsNdjsonBulkRequestWithoutThrowing(): void + { + $ndjsonBody = '{"index": {"_index": "index", "_id": "1"}}' . "\n" + . '{"name": "First document"}' . "\n" + . '{"index": {"_index": "index", "_id": "2"}}' . "\n" + . '{"name": "Second document"}' . "\n"; + + $this->panelLogger->info('Request: POST /_bulk', [ + 'request' => new \GuzzleHttp\Psr7\Request( + 'POST', + 'http://localhost:9200/_bulk', + [], + $ndjsonBody, + ), + ]); + + $queries = $this->panelLogger->getQueries(); + + \Tester\Assert::count(1, $queries); + \Tester\Assert::same('/_bulk', $queries[0]['uri']); + \Tester\Assert::contains('First document', $queries[0]['requestBody']); + \Tester\Assert::contains('Second document', $queries[0]['requestBody']); + } + + + public function testLogsUndecodableBodyAsRawString(): void + { + $this->panelLogger->info('Request: POST /index/_doc', [ + 'request' => new \GuzzleHttp\Psr7\Request( + 'POST', + 'http://localhost:9200/index/_doc', + [], + 'not json at all', + ), + ]); + + $queries = $this->panelLogger->getQueries(); + + \Tester\Assert::count(1, $queries); + \Tester\Assert::contains('not json at all', $queries[0]['requestBody']); + } + + + public function testPairsResponseWithRequest(): void + { + $this->panelLogger->info('Request: POST /index/_search', [ + 'request' => new \GuzzleHttp\Psr7\Request( + 'POST', + 'http://localhost:9200/index/_search', + [], + '{"query": {"match_all": {}}}', + ), + ]); + $this->panelLogger->info('Response (retry 0): 200', [ + 'response' => new \GuzzleHttp\Psr7\Response( + 200, + [], + '{"took": 1, "hits": {"total": {"value": 0}}}', + ), + ]); + + $queries = $this->panelLogger->getQueries(); + + \Tester\Assert::count(1, $queries); + \Tester\Assert::contains('hits', $queries[0]['responseBody']); + \Tester\Assert::true(isset($queries[0]['duration'])); + } + +} + +(new PanelLoggerTest())->run();