From 339a5fd04e4bf1d7b612148c30d7394913237b59 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 9 Apr 2026 16:35:31 +0200 Subject: [PATCH 1/2] feat(agent): add AgentClient --- src/Agent/Transport/AgentClient.php | 101 +++++++++++ tests/HttpClient/AgentClientTest.php | 91 ++++++++++ tests/HttpClient/TestAgent.php | 239 +++++++++++++++++++++++++++ tests/HttpClient/TestServer.php | 6 +- tests/HttpClient/agent-server.php | 82 +++++++++ 5 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 src/Agent/Transport/AgentClient.php create mode 100644 tests/HttpClient/AgentClientTest.php create mode 100644 tests/HttpClient/TestAgent.php create mode 100644 tests/HttpClient/agent-server.php diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php new file mode 100644 index 0000000000..a97047e1cb --- /dev/null +++ b/src/Agent/Transport/AgentClient.php @@ -0,0 +1,101 @@ +host = $host; + $this->port = $port; + } + + public function __destruct() + { + $this->disconnect(); + } + + /** + * @phpstan-assert-if-true resource $this->socket + */ + private function connect(): bool + { + if ($this->socket !== null) { + return true; + } + + // We set the timeout to 10ms to avoid blocking the request for too long if the agent is not running + // @TODO: 10ms should be low enough? Do we want to go lower and/or make this configurable? Only applies to initial connection. + $socket = fsockopen($this->host, $this->port, $errorNo, $errorMsg, 0.01); + + // @TODO: Error handling? See $errorNo and $errorMsg + if ($socket === false) { + return false; + } + + // @TODO: Set a timeout for the socket to prevent blocking (?) if the socket connection stops working after the connection (e.g. the agent is stopped) if needed + $this->socket = $socket; + + return true; + } + + private function disconnect(): void + { + if ($this->socket === null) { + return; + } + + fclose($this->socket); + + $this->socket = null; + } + + private function send(string $message): void + { + if (!$this->connect()) { + return; + } + + // @TODO: Make sure we don't send more than 2^32 - 1 bytes + $contentLength = pack('N', \strlen($message) + 4); + + // @TODO: Error handling? + fwrite($this->socket, $contentLength . $message); + } + + public function sendRequest(Request $request, Options $options): Response + { + $body = $request->getStringBody(); + + if (empty($body)) { + return new Response(400, [], 'Request body is empty'); + } + + $this->send($body); + + // Since we are sending async there is no feedback so we always return an empty response + return new Response(202, [], ''); + } +} diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php new file mode 100644 index 0000000000..fcb87bf9a7 --- /dev/null +++ b/tests/HttpClient/AgentClientTest.php @@ -0,0 +1,91 @@ +agentProcess !== null) { + $this->stopTestAgent(); + } + } + + public function testClientHandsOffEnvelopeToLocalAgent(): void + { + $this->startTestAgent(); + + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from agent client test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $client = new AgentClient('127.0.0.1', $this->agentPort); + $response = $client->sendRequest($request, new \Sentry\Options()); + + $this->waitForEnvelopeCount(1); + $agentOutput = $this->stopTestAgent(); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertCount(1, $agentOutput['messages']); + $this->assertStringContainsString('Hello from agent client test!', $agentOutput['messages'][0]); + $this->assertStringContainsString('"type":"event"', $agentOutput['messages'][0]); + } + + public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from unavailable agent test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $client = new AgentClient('127.0.0.1', 65001); + + set_error_handler(static function (): bool { + return true; + }); + + try { + $response = $client->sendRequest($request, new \Sentry\Options()); + } finally { + restore_error_handler(); + } + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + } + + public function testClientReturnsErrorWhenBodyIsEmpty(): void + { + $client = new AgentClient(); + $response = $client->sendRequest(new Request(), new \Sentry\Options()); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Request body is empty', $response->getError()); + } + + private function createEnvelope(string $dsn, string $message): string + { + $options = new Options(['dsn' => $dsn]); + + $event = Event::createEvent(); + $event->setMessage($message); + + $serializer = new PayloadSerializer($options); + + return $serializer->serialize($event); + } +} diff --git a/tests/HttpClient/TestAgent.php b/tests/HttpClient/TestAgent.php new file mode 100644 index 0000000000..836defe23e --- /dev/null +++ b/tests/HttpClient/TestAgent.php @@ -0,0 +1,239 @@ +startTestAgent()` to start the agent. + * After you are done, call `$this->stopTestAgent()` to stop the agent and get + * the captured envelopes. + */ +trait TestAgent +{ + /** + * @var resource|null the agent process handle + */ + protected $agentProcess; + + /** + * @var resource|null the agent stderr handle + */ + protected $agentStderr; + + /** + * @var string|null the path to the output file + */ + protected $agentOutputFile; + + /** + * @var int the port on which the agent is listening, this default value was randomly chosen + */ + protected $agentPort = 45848; + + /** + * Start the test agent. + * + * @return string the address the agent is listening on + */ + public function startTestAgent(): string + { + if ($this->agentProcess !== null) { + throw new \RuntimeException('There is already a test agent instance running.'); + } + + $outputFile = tempnam(sys_get_temp_dir(), 'sentry-agent-client-output-'); + + if ($outputFile === false) { + throw new \RuntimeException('Failed to create the output file for the test agent.'); + } + + $this->agentOutputFile = $outputFile; + + $pipes = []; + + $this->agentProcess = proc_open( + $command = \sprintf( + 'php %s %d %s', + escapeshellarg((string) realpath(__DIR__ . '/agent-server.php')), + $this->agentPort, + escapeshellarg($this->agentOutputFile) + ), + [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ], + $pipes + ); + + $this->agentStderr = $pipes[2]; + + $pid = proc_get_status($this->agentProcess)['pid']; + + if (!\is_resource($this->agentProcess)) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + $address = "127.0.0.1:{$this->agentPort}"; + + // Wait for the agent to be ready to accept connections + $startTime = microtime(true); + $timeout = 5; // 5 seconds timeout + + while (true) { + $socket = @stream_socket_client("tcp://{$address}", $errno, $errstr, 1); + + if ($socket !== false) { + fclose($socket); + break; + } + + if (microtime(true) - $startTime > $timeout) { + $this->stopTestAgent(); + throw new \RuntimeException("Timeout waiting for test agent to start on {$address}"); + } + + usleep(10000); + } + + // Ensure the process is still running + if (!proc_get_status($this->agentProcess)['running']) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + return $address; + } + + /** + * Wait for the test agent to receive the expected number of envelopes. + * + * @return array{ + * messages: string[], + * connections: int, + * } + */ + public function waitForEnvelopeCount(int $expectedCount, float $timeout = 5.0): array + { + if ($this->agentProcess === null) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $startTime = microtime(true); + + while (true) { + $output = $this->readAgentOutput(); + + if (\count($output['messages']) >= $expectedCount) { + return $output; + } + + if (microtime(true) - $startTime > $timeout) { + throw new \RuntimeException( + \sprintf( + 'Timeout waiting for %d envelope(s), got %d.', + $expectedCount, + \count($output['messages']) + ) + ); + } + + usleep(10000); + } + } + + /** + * Stop the test agent and return the captured envelopes. + * + * @return array{ + * messages: string[], + * connections: int, + * } + */ + public function stopTestAgent(): array + { + if (!$this->agentProcess) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $output = $this->readAgentOutput(); + + for ($i = 0; $i < 20; ++$i) { + $status = proc_get_status($this->agentProcess); + + if (!$status['running']) { + break; + } + + $this->killAgentProcess($status['pid']); + + usleep(10000); + } + + if ($status['running']) { + throw new \RuntimeException('Could not kill test agent'); + } + + proc_close($this->agentProcess); + + if ($this->agentOutputFile !== null && file_exists($this->agentOutputFile)) { + unlink($this->agentOutputFile); + } + + $this->agentProcess = null; + $this->agentStderr = null; + $this->agentOutputFile = null; + + return $output; + } + + /** + * @return array{ + * messages: string[], + * connections: int, + * } + */ + private function readAgentOutput(): array + { + if ($this->agentOutputFile === null || !file_exists($this->agentOutputFile)) { + return ['messages' => [], 'connections' => 0]; + } + + $output = file_get_contents($this->agentOutputFile); + + if ($output === false || $output === '') { + return ['messages' => [], 'connections' => 0]; + } + + $decoded = json_decode($output, true); + + if (!\is_array($decoded)) { + return ['messages' => [], 'connections' => 0]; + } + + return [ + 'messages' => $decoded['messages'] ?? [], + 'connections' => $decoded['connections'] ?? 0, + ]; + } + + private function killAgentProcess(int $pid): void + { + if (\PHP_OS_FAMILY === 'Windows') { + exec("taskkill /pid {$pid} /f /t"); + } else { + // Kills any child processes + exec("pkill -P {$pid}"); + + // Kill the parent process + exec("kill {$pid}"); + } + + proc_terminate($this->agentProcess, 9); + } +} diff --git a/tests/HttpClient/TestServer.php b/tests/HttpClient/TestServer.php index d915187ef2..8b4a1593ac 100644 --- a/tests/HttpClient/TestServer.php +++ b/tests/HttpClient/TestServer.php @@ -34,7 +34,7 @@ trait TestServer /** * @var int the port on which the server is listening, this default value was randomly chosen */ - protected $serverPort = 44884; + protected $serverPort = 45884; public function startTestServer(): string { @@ -50,9 +50,9 @@ public function startTestServer(): string $this->serverProcess = proc_open( $command = \sprintf( - 'php -S localhost:%d -t %s', + 'php -S localhost:%d %s', $this->serverPort, - realpath(__DIR__ . '/../testserver') + realpath(__DIR__ . '/../testserver/index.php') ), [2 => ['pipe', 'w']], $pipes diff --git a/tests/HttpClient/agent-server.php b/tests/HttpClient/agent-server.php new file mode 100644 index 0000000000..6a1c660f06 --- /dev/null +++ b/tests/HttpClient/agent-server.php @@ -0,0 +1,82 @@ + \n"); + + exit(1); +} + +$port = (int) $argv[1]; +$outputFile = $argv[2]; + +$server = @stream_socket_server("tcp://127.0.0.1:{$port}", $errorNo, $errorMessage); + +if ($server === false) { + fwrite(\STDERR, sprintf("Failed to start test agent server: [%d] %s\n", $errorNo, $errorMessage)); + + exit(1); +} + +$messages = []; +$connections = 0; + +$writeOutput = static function () use (&$messages, &$connections, $outputFile): void { + file_put_contents($outputFile, json_encode([ + 'messages' => $messages, + 'connections' => $connections, + ])); +}; + +$writeOutput(); + +while ($connection = @stream_socket_accept($server, -1)) { + ++$connections; + $writeOutput(); + + $buffer = ''; + $messageLength = 0; + + while (!feof($connection)) { + $chunk = fread($connection, 8192); + + if ($chunk === false) { + break; + } + + if ($chunk === '') { + continue; + } + + $buffer .= $chunk; + + while (\strlen($buffer) >= 4) { + if ($messageLength === 0) { + $unpackedHeader = unpack('N', substr($buffer, 0, 4)); + + if ($unpackedHeader === false) { + break 2; + } + + $messageLength = $unpackedHeader[1]; + } + + if (\strlen($buffer) < $messageLength) { + break; + } + + $messages[] = substr($buffer, 4, $messageLength - 4); + $buffer = (string) substr($buffer, $messageLength); + $messageLength = 0; + + $writeOutput(); + } + } + + fclose($connection); +} From 3aab6cdac22c5df7843e6fdfb892eae705c881b3 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 9 Apr 2026 16:39:42 +0200 Subject: [PATCH 2/2] CS --- tests/HttpClient/AgentClientTest.php | 6 +++--- tests/HttpClient/TestAgent.php | 8 +------- tests/HttpClient/agent-server.php | 4 ++-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php index fcb87bf9a7..1887c05446 100644 --- a/tests/HttpClient/AgentClientTest.php +++ b/tests/HttpClient/AgentClientTest.php @@ -32,7 +32,7 @@ public function testClientHandsOffEnvelopeToLocalAgent(): void $request->setStringBody($envelope); $client = new AgentClient('127.0.0.1', $this->agentPort); - $response = $client->sendRequest($request, new \Sentry\Options()); + $response = $client->sendRequest($request, new Options()); $this->waitForEnvelopeCount(1); $agentOutput = $this->stopTestAgent(); @@ -58,7 +58,7 @@ public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void }); try { - $response = $client->sendRequest($request, new \Sentry\Options()); + $response = $client->sendRequest($request, new Options()); } finally { restore_error_handler(); } @@ -70,7 +70,7 @@ public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void public function testClientReturnsErrorWhenBodyIsEmpty(): void { $client = new AgentClient(); - $response = $client->sendRequest(new Request(), new \Sentry\Options()); + $response = $client->sendRequest(new Request(), new Options()); $this->assertSame(400, $response->getStatusCode()); $this->assertTrue($response->hasError()); diff --git a/tests/HttpClient/TestAgent.php b/tests/HttpClient/TestAgent.php index 836defe23e..9e29063c47 100644 --- a/tests/HttpClient/TestAgent.php +++ b/tests/HttpClient/TestAgent.php @@ -134,13 +134,7 @@ public function waitForEnvelopeCount(int $expectedCount, float $timeout = 5.0): } if (microtime(true) - $startTime > $timeout) { - throw new \RuntimeException( - \sprintf( - 'Timeout waiting for %d envelope(s), got %d.', - $expectedCount, - \count($output['messages']) - ) - ); + throw new \RuntimeException(\sprintf('Timeout waiting for %d envelope(s), got %d.', $expectedCount, \count($output['messages']))); } usleep(10000); diff --git a/tests/HttpClient/agent-server.php b/tests/HttpClient/agent-server.php index 6a1c660f06..d81c981b5c 100644 --- a/tests/HttpClient/agent-server.php +++ b/tests/HttpClient/agent-server.php @@ -55,7 +55,7 @@ $buffer .= $chunk; - while (\strlen($buffer) >= 4) { + while (strlen($buffer) >= 4) { if ($messageLength === 0) { $unpackedHeader = unpack('N', substr($buffer, 0, 4)); @@ -66,7 +66,7 @@ $messageLength = $unpackedHeader[1]; } - if (\strlen($buffer) < $messageLength) { + if (strlen($buffer) < $messageLength) { break; }