diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php index a97047e1c..fdb17aba9 100644 --- a/src/Agent/Transport/AgentClient.php +++ b/src/Agent/Transport/AgentClient.php @@ -11,6 +11,8 @@ class AgentClient implements HttpClientInterface { + private const SOCKET_TIMEOUT_SECONDS = 0.01; + /** * @var string */ @@ -26,10 +28,34 @@ class AgentClient implements HttpClientInterface */ private $socket; - public function __construct(string $host = '127.0.0.1', int $port = 5148) + /** + * @var HttpClientInterface|null + */ + private $fallbackClient; + + /** + * @var (callable(): HttpClientInterface)|null + */ + private $fallbackClientFactory; + + /** + * @var string|null + */ + private $fallbackClientError; + + /** + * @var string + */ + private $lastSendError = ''; + + /** + * @phpstan-param (callable(): HttpClientInterface)|null $fallbackClientFactory + */ + public function __construct(string $host = '127.0.0.1', int $port = 5148, ?callable $fallbackClientFactory = null) { $this->host = $host; $this->port = $port; + $this->fallbackClientFactory = $fallbackClientFactory; } public function __destruct() @@ -46,16 +72,26 @@ private function connect(): bool 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); + // 10ms connect timeout to avoid blocking the request if the agent is not running + $errorNo = 0; + $errorMsg = ''; + $socket = @fsockopen($this->host, $this->port, $errorNo, $errorMsg, self::SOCKET_TIMEOUT_SECONDS); - // @TODO: Error handling? See $errorNo and $errorMsg if ($socket === false) { + $this->lastSendError = \sprintf( + 'Failed to connect to the local Sentry agent at %s:%d. [%d] %s', + $this->host, + $this->port, + $errorNo, + $errorMsg + ); + 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 + // Use non-blocking writes with stream_select() so a hung agent cannot block the caller indefinitely. + stream_set_blocking($socket, false); + $this->socket = $socket; return true; @@ -72,17 +108,120 @@ private function disconnect(): void $this->socket = null; } - private function send(string $message): void + private function send(string $message): bool { - if (!$this->connect()) { - return; + $this->lastSendError = ''; + + $payload = pack('N', \strlen($message) + 4) . $message; + + // Attempt to send the payload, retrying once on write failure to handle + // stale sockets (e.g. agent restarts in long-running workers). + for ($attempt = 0; $attempt < 2; ++$attempt) { + if (!$this->connect()) { + return false; + } + + if ($this->writePayload($payload)) { + return true; + } + + $this->disconnect(); + } + + $this->lastSendError = \sprintf( + 'Failed to write envelope to the local Sentry agent at %s:%d.', + $this->host, + $this->port + ); + + return false; + } + + private function writePayload(string $payload): bool + { + if ($this->socket === null) { + return false; + } + + $socket = $this->socket; + $payloadLength = \strlen($payload); + $totalWrittenBytes = 0; + $writeDeadline = microtime(true) + self::SOCKET_TIMEOUT_SECONDS; + + while ($totalWrittenBytes < $payloadLength) { + if (!$this->waitUntilSocketIsWritable($socket, $writeDeadline)) { + return false; + } + + $bytesWritten = @fwrite($socket, (string) substr($payload, $totalWrittenBytes)); + + if ($bytesWritten === false) { + return false; + } + + $totalWrittenBytes += $bytesWritten; + } + + return true; + } + + /** + * @param resource $socket + */ + private function waitUntilSocketIsWritable($socket, float $deadline): bool + { + $remainingSeconds = $deadline - microtime(true); + + if ($remainingSeconds <= 0) { + return false; + } + + $readSockets = null; + $writeSockets = [$socket]; + $exceptSockets = null; + $selectedSockets = @stream_select( + $readSockets, + $writeSockets, + $exceptSockets, + 0, + (int) ceil($remainingSeconds * 1000000) + ); + + return $selectedSockets !== false && $selectedSockets > 0; + } + + private function getFallbackClient(): ?HttpClientInterface + { + if ($this->fallbackClient !== null) { + return $this->fallbackClient; + } + + if ($this->fallbackClientFactory === null) { + return null; } - // @TODO: Make sure we don't send more than 2^32 - 1 bytes - $contentLength = pack('N', \strlen($message) + 4); + try { + $fallbackClient = ($this->fallbackClientFactory)(); + } catch (\Throwable $exception) { + $this->fallbackClientFactory = null; + $this->fallbackClientError = \sprintf( + 'Failed to initialize fallback HTTP client. Reason: "%s". Fallback delivery has been disabled.', + $exception->getMessage() + ); + + return null; + } - // @TODO: Error handling? - fwrite($this->socket, $contentLength . $message); + if (!$fallbackClient instanceof HttpClientInterface) { + $this->fallbackClientFactory = null; + $this->fallbackClientError = 'The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.'; + + return null; + } + + $this->fallbackClient = $fallbackClient; + + return $this->fallbackClient; } public function sendRequest(Request $request, Options $options): Response @@ -93,9 +232,46 @@ public function sendRequest(Request $request, Options $options): Response return new Response(400, [], 'Request body is empty'); } - $this->send($body); + if ($this->send($body)) { + // Since we are sending async there is no feedback so we always return an empty response + return new Response(202, [], ''); + } + + $logContext = [ + 'agent_host' => $this->host, + 'agent_port' => $this->port, + ]; + + if ($this->lastSendError !== '') { + $logContext['error'] = $this->lastSendError; + } + + $options->getLoggerOrNullLogger()->debug('Failed to hand off envelope to local Sentry agent.', $logContext); + + $fallbackClient = $this->getFallbackClient(); + if ($fallbackClient !== null) { + $options->getLoggerOrNullLogger()->debug('Using fallback HTTP client because local Sentry agent handoff failed.', $logContext); + + try { + return $fallbackClient->sendRequest($request, $options); + } catch (\Throwable $exception) { + $options->getLoggerOrNullLogger()->debug( + 'Fallback HTTP client failed while sending envelope.', + array_merge($logContext, ['exception' => $exception]) + ); + + return new Response(502, [], \sprintf( + 'Failed to send envelope using fallback HTTP client. Reason: "%s".', + $exception->getMessage() + )); + } + } + + if ($this->fallbackClientError !== null) { + $options->getLoggerOrNullLogger()->debug($this->fallbackClientError, $logContext); + $this->fallbackClientError = null; + } - // Since we are sending async there is no feedback so we always return an empty response - return new Response(202, [], ''); + return new Response(502, [], 'Failed to send envelope to the local Sentry agent and no fallback client is available.'); } } diff --git a/src/Agent/Transport/AgentClientBuilder.php b/src/Agent/Transport/AgentClientBuilder.php new file mode 100644 index 000000000..dcdd6bb2a --- /dev/null +++ b/src/Agent/Transport/AgentClientBuilder.php @@ -0,0 +1,127 @@ +host = $host; + + return $this; + } + + public function setPort(int $port): self + { + $this->port = $port; + + return $this; + } + + public function disableFallbackClient(): self + { + $this->isFallbackClientDisabled = true; + $this->fallbackClientFactory = null; + + return $this; + } + + public function setFallbackClient(HttpClientInterface $fallbackClient): self + { + return $this->setFallbackClientFactory(static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + } + + /** + * @phpstan-param callable(): HttpClientInterface $fallbackClientFactory + */ + public function setFallbackClientFactory(callable $fallbackClientFactory): self + { + $this->isFallbackClientDisabled = false; + $this->fallbackClientFactory = $fallbackClientFactory; + + return $this; + } + + public function setSdkIdentifier(string $sdkIdentifier): self + { + $this->sdkIdentifier = $sdkIdentifier; + + return $this; + } + + public function setSdkVersion(string $sdkVersion): self + { + $this->sdkVersion = $sdkVersion; + + return $this; + } + + public function getClient(): AgentClient + { + if ($this->isFallbackClientDisabled) { + return new AgentClient($this->host, $this->port, null); + } + + if ($this->fallbackClientFactory !== null) { + return new AgentClient($this->host, $this->port, $this->fallbackClientFactory); + } + + return new AgentClient($this->host, $this->port, $this->createDefaultFallbackClientFactory()); + } + + /** + * @return callable(): HttpClientInterface + */ + private function createDefaultFallbackClientFactory(): callable + { + $sdkIdentifier = $this->sdkIdentifier; + $sdkVersion = $this->sdkVersion; + + return static function () use ($sdkIdentifier, $sdkVersion): HttpClientInterface { + return new HttpClient($sdkIdentifier, $sdkVersion); + }; + } +} diff --git a/tests/HttpClient/AgentClientBuilderTest.php b/tests/HttpClient/AgentClientBuilderTest.php new file mode 100644 index 000000000..ffcd72f0e --- /dev/null +++ b/tests/HttpClient/AgentClientBuilderTest.php @@ -0,0 +1,171 @@ +serverProcess !== null) { + $this->stopTestServer(); + } + + StubLogger::$logs = []; + } + + public function testBuilderUsesFallbackClientByDefaultWhenLocalAgentIsUnavailable(): void + { + $testServer = $this->startTestServer(); + $dsn = "http://publicKey@{$testServer}/200"; + + $envelope = $this->createEnvelope($dsn, 'Hello from builder default fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => $dsn]); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->getClient(); + $response = $client->sendRequest($request, $options); + + $serverOutput = $this->stopTestServer(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertStringContainsString('Hello from builder default fallback test!', $serverOutput['body']); + } + + public function testBuilderCanDisableFallbackClient(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from disabled fallback builder test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->disableFallbackClient() + ->getClient(); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope to the local Sentry agent and no fallback client is available.', $response->getError()); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + $this->assertFalse($this->hasLogMessage('Using fallback HTTP client because local Sentry agent handoff failed.')); + } + + public function testBuilderUsesCustomFallbackClientWhenConfigured(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from custom fallback builder test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => 'http://public@example.com/1']); + $fallbackResponse = new Response(201, [], ''); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->setFallbackClient($fallbackClient) + ->getClient(); + $response = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $response); + } + + public function testBuilderCreatesDefaultFallbackClientWithConfiguredSdkIdentifierAndVersion(): void + { + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->setSdkIdentifier('sentry.test') + ->setSdkVersion('1.2.3-test') + ->getClient(); + + $fallbackClientFactoryProperty = new \ReflectionProperty($client, 'fallbackClientFactory'); + if (\PHP_VERSION_ID < 80100) { + $fallbackClientFactoryProperty->setAccessible(true); + } + + /** @var mixed $fallbackClientFactory */ + $fallbackClientFactory = $fallbackClientFactoryProperty->getValue($client); + + $this->assertIsCallable($fallbackClientFactory); + $fallbackClient = $fallbackClientFactory(); + $this->assertInstanceOf(HttpClient::class, $fallbackClient); + + $sdkIdentifierProperty = new \ReflectionProperty($fallbackClient, 'sdkIdentifier'); + $sdkVersionProperty = new \ReflectionProperty($fallbackClient, 'sdkVersion'); + if (\PHP_VERSION_ID < 80100) { + $sdkIdentifierProperty->setAccessible(true); + $sdkVersionProperty->setAccessible(true); + } + + $this->assertSame('sentry.test', $sdkIdentifierProperty->getValue($fallbackClient)); + $this->assertSame('1.2.3-test', $sdkVersionProperty->getValue($fallbackClient)); + } + + 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); + } + + private function hasLogMessage(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if ($log['message'] === $message) { + return true; + } + } + + return false; + } +} diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php index 1887c0544..4d1db39fc 100644 --- a/tests/HttpClient/AgentClientTest.php +++ b/tests/HttpClient/AgentClientTest.php @@ -7,19 +7,35 @@ use PHPUnit\Framework\TestCase; use Sentry\Agent\Transport\AgentClient; use Sentry\Event; +use Sentry\HttpClient\HttpClientInterface; use Sentry\HttpClient\Request; +use Sentry\HttpClient\Response; use Sentry\Options; use Sentry\Serializer\PayloadSerializer; +use Sentry\Tests\StubLogger; final class AgentClientTest extends TestCase { use TestAgent; + // Reserved address TEST-NET-1, which should not be bound for anything + private const UNAVAILABLE_AGENT_HOST = '192.0.2.1'; + private const UNAVAILABLE_AGENT_PORT = 5148; + + protected function setUp(): void + { + parent::setUp(); + + StubLogger::$logs = []; + } + protected function tearDown(): void { if ($this->agentProcess !== null) { $this->stopTestAgent(); } + + StubLogger::$logs = []; } public function testClientHandsOffEnvelopeToLocalAgent(): void @@ -31,7 +47,14 @@ public function testClientHandsOffEnvelopeToLocalAgent(): void $request = new Request(); $request->setStringBody($envelope); - $client = new AgentClient('127.0.0.1', $this->agentPort); + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->never()) + ->method('sendRequest'); + + $client = new AgentClient('127.0.0.1', $this->agentPort, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); $response = $client->sendRequest($request, new Options()); $this->waitForEnvelopeCount(1); @@ -44,27 +67,149 @@ public function testClientHandsOffEnvelopeToLocalAgent(): void $this->assertStringContainsString('"type":"event"', $agentOutput['messages'][0]); } - public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void + public function testClientReturnsErrorAndLogsDebugWhenLocalAgentIsUnavailableWithoutFallback(): 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); + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, null); + $response = $client->sendRequest($request, $options); - set_error_handler(static function (): bool { - return true; - }); + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope to the local Sentry agent and no fallback client is available.', $response->getError()); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + } - try { - $response = $client->sendRequest($request, new Options()); - } finally { - restore_error_handler(); - } + public function testClientLazilyInitializesFallbackFactoryOnlyWhenNeeded(): void + { + $this->startTestAgent(); + + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from lazy fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $factoryCallCount = 0; + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->never()) + ->method('sendRequest'); + + $client = new AgentClient( + '127.0.0.1', + $this->agentPort, + static function () use (&$factoryCallCount, $fallbackClient): HttpClientInterface { + ++$factoryCallCount; + + return $fallbackClient; + } + ); + $response = $client->sendRequest($request, new Options()); + + $this->waitForEnvelopeCount(1); + $this->stopTestAgent(); $this->assertSame(202, $response->getStatusCode()); $this->assertSame('', $response->getError()); + $this->assertSame(0, $factoryCallCount); + } + + public function testClientUsesFallbackClientWhenLocalAgentIsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options([ + 'dsn' => 'http://public@example.com/1', + 'logger' => $logger, + ]); + + $fallbackResponse = new Response(200, [], ''); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $response); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + $this->assertTrue($this->hasLogMessage('Using fallback HTTP client because local Sentry agent handoff failed.')); + } + + public function testClientReusesFallbackClientWhenLocalAgentRemainsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from cached fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => 'http://public@example.com/1']); + $fallbackResponse = new Response(200, [], ''); + $factoryCallCount = 0; + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->exactly(2)) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use (&$factoryCallCount, $fallbackClient): HttpClientInterface { + ++$factoryCallCount; + + return $fallbackClient; + }); + + $firstResponse = $client->sendRequest($request, $options); + $secondResponse = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $firstResponse); + $this->assertSame($fallbackResponse, $secondResponse); + $this->assertSame(1, $factoryCallCount); + } + + public function testClientDoesNotThrowWhenFallbackClientThrows(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from throwing fallback client test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willThrowException(new \RuntimeException('fallback boom')); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope using fallback HTTP client. Reason: "fallback boom".', $response->getError()); + $this->assertTrue($this->hasLogMessage('Fallback HTTP client failed while sending envelope.')); } public function testClientReturnsErrorWhenBodyIsEmpty(): void @@ -77,6 +222,78 @@ public function testClientReturnsErrorWhenBodyIsEmpty(): void $this->assertSame('Request body is empty', $response->getError()); } + public function testClientDoesNotThrowWhenFallbackFactoryThrows(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from throwing fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function (): HttpClientInterface { + throw new \RuntimeException('factory boom'); + } + ); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertTrue($this->hasLogMessageContaining('Failed to initialize fallback HTTP client.')); + } + + public function testClientLogsFallbackFactoryErrorOnlyOnce(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from repeated throwing fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function (): HttpClientInterface { + throw new \RuntimeException('factory boom'); + } + ); + + $client->sendRequest($request, $options); + $client->sendRequest($request, $options); + + $this->assertSame(1, $this->countLogMessagesContaining('Failed to initialize fallback HTTP client.')); + } + + public function testClientDoesNotThrowWhenFallbackFactoryReturnsUnexpectedValue(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from invalid fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function () { + return new \stdClass(); + } + ); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertTrue($this->hasLogMessage('The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.')); + } + private function createEnvelope(string $dsn, string $message): string { $options = new Options(['dsn' => $dsn]); @@ -88,4 +305,29 @@ private function createEnvelope(string $dsn, string $message): string return $serializer->serialize($event); } + + private function hasLogMessage(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if ($log['message'] === $message) { + return true; + } + } + + return false; + } + + private function countLogMessagesContaining(string $message): int + { + $result = array_filter(StubLogger::$logs, static function (array $log) use ($message): bool { + return strpos($log['message'], $message) !== false; + }); + + return \count($result); + } + + private function hasLogMessageContaining(string $message): bool + { + return $this->countLogMessagesContaining($message) > 0; + } }