diff --git a/src/Monitoring/SignozServiceLogger.php b/src/Monitoring/SignozServiceLogger.php index 0ae78df3..5d601eec 100644 --- a/src/Monitoring/SignozServiceLogger.php +++ b/src/Monitoring/SignozServiceLogger.php @@ -14,8 +14,29 @@ class SignozServiceLogger private const API_KEY = '%%SIGNOZ_API_KEY%%'; private const LIBRARY = 'PHP'; + // --- Health check --- + private const HEALTH_PATH = '/health/ready'; + private const HEALTH_CACHE_TTL = 60; // seconds a successful health check is trusted + + // --- Circuit breaker --- + private const CB_FAILURE_THRESHOLD = 3; // consecutive failures before opening + private const CB_OPEN_TTL = 120; // seconds the circuit stays open (cooldown) + private const CB_FAILURES_KEY = 'signoz:cb:failures'; + private const CB_OPEN_UNTIL_KEY = 'signoz:cb:open_until'; + private const HEALTH_OK_KEY = 'signoz:health:ok_until'; + + // --- Retry / backoff (503 only) --- + private const MAX_ATTEMPTS = 3; // total attempts (1 initial + 2 retries) + private const BASE_DELAY_MS = 200; // backoff base + private const MAX_DELAY_MS = 1500; // per-retry delay cap + private static bool $appCreatedSent = false; + // In-process fallbacks when no PSR cache is configured. + private static int $staticFailureCount = 0; + private static int $staticOpenUntil = 0; + private static int $staticHealthyUntil = 0; + private ClientInterface $httpClient; private ?CacheInterface $cache; private string $libraryVersion; @@ -141,8 +162,6 @@ public function trackRequestSent( 'reference' => $safeReference, ]; - // error_log('Signoz Request Sent reference: ' . $reference); - $cacheKey = sprintf( 'signoz:request_sent:%s', $safeReference @@ -160,7 +179,7 @@ public function trackRequestSent( // observability must never break payments } } - + $this->send('request.sent', $payload); } @@ -199,37 +218,224 @@ public function trackError( private function send(string $eventName, array $data): void { try { - $this->httpClient->request('POST', self::BASE_URL . '/events', [ + // 1. Circuit breaker gate: if open, drop the event immediately. + if ($this->isCircuitOpen()) { + return; + } + + // 2. Health gate: verify /health/ready (cached for HEALTH_CACHE_TTL). + // When the circuit has just moved out of cooldown, this acts as + // the half-open probe before real traffic resumes. + if (!$this->isServiceHealthy()) { + $this->recordFailure(); + return; + } + + $this->sendWithRetry($eventName, $data); + } catch (\Throwable $e) { + // observability must never break payments + } + } + + private function sendWithRetry(string $eventName, array $data): void + { + $body = [ + 'name' => $eventName, + 'data' => $data, + 'timestamp' => gmdate('Y-m-d\TH:i:s.000\Z'), + ]; + + for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) { + try { + $this->httpClient->request('POST', self::BASE_URL . '/events', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'x-api-key' => self::API_KEY, + ], + 'json' => $body, + + // fire-and-forget-ish + 'timeout' => 2, + 'connect_timeout' => 1, + ]); + + $this->recordSuccess(); + return; + } catch (RequestException $e) { + $response = $e->getResponse(); + $statusCode = $response !== null ? $response->getStatusCode() : 0; + + // Only 503 (service temporarily unavailable) is retried. + if ($statusCode === 503 && $attempt < self::MAX_ATTEMPTS) { + $this->backoffSleep($attempt); + continue; + } + + $this->recordFailure(); + return; + } catch (\Throwable $e) { + // Network/transport errors: count as a failure, never throw. + $this->recordFailure(); + return; + } + } + + // Exhausted retries (all 503s). + $this->recordFailure(); + } + + /** + * Exponential backoff with full jitter: + * sleep for random(0, min(MAX_DELAY, BASE * 2^(attempt-1))). + */ + private function backoffSleep(int $attempt): void + { + $ceilingMs = min( + self::MAX_DELAY_MS, + self::BASE_DELAY_MS * (2 ** ($attempt - 1)) + ); + + try { + $delayMs = random_int(0, $ceilingMs); + } catch (\Throwable $e) { + $delayMs = $ceilingMs; + } + + usleep($delayMs * 1000); + } + + /** + * GET /health/ready and require {"status":"ok"}. + * A passing check is cached so we don't probe on every event. + */ + private function isServiceHealthy(): bool + { + $now = time(); + + // Trust a recent successful health check. + if ($this->cache !== null) { + try { + if ($this->cache->has(self::HEALTH_OK_KEY)) { + return true; + } + } catch (\Throwable $e) { + // fall through to live probe + } + } elseif (self::$staticHealthyUntil > $now) { + return true; + } + + try { + $response = $this->httpClient->request('GET', self::BASE_URL . self::HEALTH_PATH, [ 'headers' => [ - 'Content-Type' => 'application/json', - 'x-api-key' => self::API_KEY, + 'x-api-key' => self::API_KEY, ], - 'json' => [ - 'name' => $eventName, - 'data' => $data, - 'timestamp' => gmdate('Y-m-d\TH:i:s.000\Z'), - ], - - // fire-and-forget-ish - 'timeout' => 2, + 'timeout' => 1, 'connect_timeout' => 1, ]); - } catch (RequestException $e) { - $response = $e->getResponse(); - - // if ($response !== null && $response->getStatusCode() === 422) { - // $responseBody = (string) $response->getBody(); - // error_log(sprintf( - // 'Signoz validation error (422) while sending %s: %s', - // $eventName, - // $responseBody - // )); - // } + + if ($response->getStatusCode() !== 200) { + return false; + } + + $result = json_decode($response->getBody()->getContents(), true); + + $healthy = is_array($result) + && isset($result['status']) + && $result['status'] === 'ok'; + + if ($healthy) { + if ($this->cache !== null) { + try { + $this->cache->set(self::HEALTH_OK_KEY, true, self::HEALTH_CACHE_TTL); + } catch (\Throwable $e) { + // observability must never break payments + } + } else { + self::$staticHealthyUntil = $now + self::HEALTH_CACHE_TTL; + } + } + + return $healthy; } catch (\Throwable $e) { - // observability must never break payments + return false; + } + } + + // --- Circuit breaker state ------------------------------------------- + + private function isCircuitOpen(): bool + { + $now = time(); + + if ($this->cache !== null) { + try { + $openUntil = (int) ($this->cache->get(self::CB_OPEN_UNTIL_KEY, 0)); + return $openUntil > $now; + } catch (\Throwable $e) { + // fall back to in-process state + } + } + + return self::$staticOpenUntil > $now; + } + + private function recordSuccess(): void + { + self::$staticFailureCount = 0; + self::$staticOpenUntil = 0; + + if ($this->cache !== null) { + try { + $this->cache->delete(self::CB_FAILURES_KEY); + $this->cache->delete(self::CB_OPEN_UNTIL_KEY); + } catch (\Throwable $e) { + // observability must never break payments + } } } + private function recordFailure(): void + { + $failures = ++self::$staticFailureCount; + + if ($this->cache !== null) { + try { + $failures = (int) $this->cache->get(self::CB_FAILURES_KEY, 0) + 1; + $this->cache->set(self::CB_FAILURES_KEY, $failures, self::CB_OPEN_TTL * 2); + } catch (\Throwable $e) { + // keep in-process count + } + } + + if ($failures >= self::CB_FAILURE_THRESHOLD) { + $this->openCircuit(); + } + } + + private function openCircuit(): void + { + $openUntil = time() + self::CB_OPEN_TTL; + + self::$staticOpenUntil = $openUntil; + self::$staticFailureCount = 0; + + if ($this->cache !== null) { + try { + $this->cache->set(self::CB_OPEN_UNTIL_KEY, $openUntil, self::CB_OPEN_TTL); + $this->cache->delete(self::CB_FAILURES_KEY); + + // Invalidate the cached health status so the next attempt + // after cooldown re-probes /health/ready (half-open behavior). + $this->cache->delete(self::HEALTH_OK_KEY); + } catch (\Throwable $e) { + // observability must never break payments + } + } + + self::$staticHealthyUntil = 0; + } + private function normalizeAppId(string $appId): string { return preg_replace('/\s+/', '-', trim($appId)) ?? $appId; diff --git a/tests/Unit/Monitoring/SignozServiceLoggerTest.php b/tests/Unit/Monitoring/SignozServiceLoggerTest.php index 99291c50..e7a72bde 100644 --- a/tests/Unit/Monitoring/SignozServiceLoggerTest.php +++ b/tests/Unit/Monitoring/SignozServiceLoggerTest.php @@ -6,92 +6,366 @@ use Flutterwave\Monitoring\SignozServiceLogger; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; use Psr\SimpleCache\CacheInterface; use ReflectionClass; -use ReflectionProperty; class SignozServiceLoggerTest extends TestCase { + private const BASE_URL = 'https://signozservice-prod.f4b-flutterwave.com'; + private const EVENTS_URL = self::BASE_URL . '/events'; + private const HEALTH_URL = self::BASE_URL . '/health/ready'; + private const MERC_INFO_URL = 'https://api.ravepay.co/flwv3-pug/getpaidx/api/mercinfo?PBFPubKey='; + + protected function setUp(): void + { + $this->resetStaticState(); + } protected function tearDown(): void { - $this->resetAppCreatedFlag(); - } - - // public function testAppCreatedIsSentOnlyOncePerPublicKey(): void - // { - // $publicKey = getEnv('PUBLIC_KEY'); - // $firstHttpClient = $this->createMock(ClientInterface::class); - // $secondHttpClient = $this->createMock(ClientInterface::class); - // $cache = $this->createMock(CacheInterface::class); - - // $cacheKey = sprintf('signoz:app_created:%s', hash('sha256', $publicKey)); - - // $cache->expects($this->exactly(2)) - // ->method('has') - // ->with($cacheKey) - // ->willReturnOnConsecutiveCalls(false, true); - - // $cache->expects($this->once()) - // ->method('set') - // ->with($cacheKey, true); - - // $firstHttpClient->expects($this->exactly(2)) - // ->method('request') - // ->withConsecutive( - // [ - // 'GET', - // 'https://api.ravepay.co/flwv3-pug/getpaidx/api/mercinfo?PBFPubKey=' . $publicKey, - // $this->callback(static function (array $options): bool { - // return isset($options['headers']['Content-Type']) && $options['headers']['Content-Type'] === 'application/json'; - // }), - // ], - // [ - // 'POST', - // 'https://signozservice-prod.f4b-flutterwave.com/events', - // $this->callback(static function (array $options): bool { - // if (!isset($options['json']['name'], $options['json']['data']['public_key'])) { - // return false; - // } - - // return $options['json']['name'] === 'app.created' - // && $options['json']['data']['public_key'] === $publicKey; - // }), - // ] - // ) - // ->willReturnOnConsecutiveCalls( - // new Response(200, [], json_encode(['mn' => 'Bajoski Software Developement'])), - // new Response(200) - // ); - - // $logger = new SignozServiceLogger($firstHttpClient, $publicKey, 'sandbox', $cache, '1.0.7'); - // $logger->trackAppCreated($publicKey); - - // $this->resetAppCreatedFlag(); - - // $secondHttpClient->expects($this->never()) - // ->method('request') - // ->with($this->anything(), $this->anything(), $this->anything()); - - // $cache->expects($this->once()) - // ->method('has') - // ->with($cacheKey) - // ->willReturn(true); - - // $cache->expects($this->never()) - // ->method('set'); - - // $secondLogger = new SignozServiceLogger($secondHttpClient, $publicKey, 'sandbox', $cache, '1.0.7'); - // $secondLogger->trackAppCreated($publicKey); - // } - - private function resetAppCreatedFlag(): void + $this->resetStaticState(); + } + + // ----------------------------------------------------------------- + // Health check gate + // ----------------------------------------------------------------- + + public function testEventIsSentWhenServiceIsHealthy(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + if ($uri === self::HEALTH_URL) { + return $this->healthyResponse(); + } + + return new Response(200); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + $this->assertCount(2, $calls); + $this->assertSame(['GET', self::HEALTH_URL], [$calls[0]['method'], $calls[0]['uri']]); + $this->assertSame(['POST', self::EVENTS_URL], [$calls[1]['method'], $calls[1]['uri']]); + + // Health probe must carry the API key header. + $this->assertArrayHasKey('x-api-key', $calls[0]['options']['headers']); + + // Event payload sanity check. + $this->assertSame('app.error', $calls[1]['options']['json']['name']); + $this->assertSame('app-1', $calls[1]['options']['json']['data']['app_id']); + } + + public function testHealthCheckIsCachedAcrossEvents(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + return $uri === self::HEALTH_URL ? $this->healthyResponse() : new Response(200); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_ONE', 'first'); + $logger->trackError('app-1', 'ERR_TWO', 'second'); + + // 1 health probe + 2 event POSTs — the second event reuses the + // cached health result instead of probing again. + $this->assertCount(3, $calls); + $this->assertSame(self::HEALTH_URL, $calls[0]['uri']); + $this->assertSame(self::EVENTS_URL, $calls[1]['uri']); + $this->assertSame(self::EVENTS_URL, $calls[2]['uri']); + } + + public function testEventIsDroppedWhenHealthStatusIsNotOk(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + if ($uri === self::HEALTH_URL) { + return new Response(200, [], json_encode([ + 'status' => 'degraded', + 'dependencies' => ['redis' => 'down'], + ])); + } + + $this->fail('No event should be sent when the service is unhealthy.'); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + // Only the health probe — no POST to /events. + $this->assertCount(1, $calls); + $this->assertSame(self::HEALTH_URL, $calls[0]['uri']); + $this->assertSame(1, $this->getStaticValue('staticFailureCount')); + } + + // ----------------------------------------------------------------- + // 503 retry with backoff + // ----------------------------------------------------------------- + + public function testRetriesUpToMaxAttemptsOn503(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + if ($uri === self::HEALTH_URL) { + return $this->healthyResponse(); + } + + throw $this->serviceUnavailableException(); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + // 1 health probe + 3 POST attempts (MAX_ATTEMPTS). + $this->assertCount(4, $calls); + $this->assertSame(self::HEALTH_URL, $calls[0]['uri']); + foreach (array_slice($calls, 1) as $call) { + $this->assertSame(self::EVENTS_URL, $call['uri']); + } + + // Exhausted retries count as one breaker failure. + $this->assertSame(1, $this->getStaticValue('staticFailureCount')); + } + + public function testDoesNotRetryOnNon503Errors(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + if ($uri === self::HEALTH_URL) { + return $this->healthyResponse(); + } + + throw new RequestException( + 'Unprocessable Entity', + new Request('POST', self::EVENTS_URL), + new Response(422) + ); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + // 1 health probe + exactly 1 POST attempt — 422 is not retried. + $this->assertCount(2, $calls); + $this->assertSame(1, $this->getStaticValue('staticFailureCount')); + } + + // ----------------------------------------------------------------- + // Circuit breaker + // ----------------------------------------------------------------- + + public function testCircuitOpensAfterConsecutiveFailuresAndBlocksSends(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + // Every health probe fails -> every send records a failure. + throw new RequestException( + 'Connection refused', + new Request('GET', self::HEALTH_URL) + ); + }); + + $logger = $this->makeLogger($httpClient); + + // Three failures reach CB_FAILURE_THRESHOLD and open the circuit. + $logger->trackError('app-1', 'ERR_1', 'first'); + $logger->trackError('app-1', 'ERR_2', 'second'); + $logger->trackError('app-1', 'ERR_3', 'third'); + + $this->assertCount(3, $calls); + $this->assertGreaterThan(time(), $this->getStaticValue('staticOpenUntil')); + + // Circuit is open: this send must make zero HTTP calls. + $logger->trackError('app-1', 'ERR_4', 'fourth'); + $this->assertCount(3, $calls); + } + + public function testSuccessfulSendResetsCircuitBreakerState(): void + { + $shouldFail = true; + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) use (&$shouldFail) { + if ($uri === self::HEALTH_URL) { + if ($shouldFail) { + throw new RequestException('down', new Request('GET', self::HEALTH_URL)); + } + + return $this->healthyResponse(); + } + + return new Response(200); + }); + + $logger = $this->makeLogger($httpClient); + + // Two failures — one short of the threshold. + $logger->trackError('app-1', 'ERR_1', 'first'); + $logger->trackError('app-1', 'ERR_2', 'second'); + $this->assertSame(2, $this->getStaticValue('staticFailureCount')); + + // Service recovers; a successful send resets the breaker. + $shouldFail = false; + $logger->trackError('app-1', 'ERR_3', 'third'); + + $this->assertSame(0, $this->getStaticValue('staticFailureCount')); + $this->assertSame(0, $this->getStaticValue('staticOpenUntil')); + } + + public function testCircuitBreakerStateIsSharedViaCacheWhenAvailable(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('request'); + + $cache = $this->createMock(CacheInterface::class); + + // Another process already opened the circuit. + $cache->method('get') + ->with('signoz:cb:open_until', 0) + ->willReturn(time() + 60); + + $logger = $this->makeLogger($httpClient, $cache); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + } + + // ----------------------------------------------------------------- + // app.created flow (updated for the health-check gate) + // ----------------------------------------------------------------- + + public function testAppCreatedIsSentOnlyOncePerPublicKey(): void + { + $publicKey = getenv('PUBLIC_KEY') ?: 'FLWPUBK_TEST-0000000000000000000000000000000-X'; + $cacheKey = sprintf('signoz:app_created:%s', hash('sha256', $publicKey)); + + $calls = []; + $firstHttpClient = $this->mockHttpClient($calls, function (string $method, string $uri) use ($publicKey) { + if ($uri === self::MERC_INFO_URL . $publicKey) { + return new Response(200, [], json_encode(['mn' => 'Bajoski Software Developement'])); + } + + if ($uri === self::HEALTH_URL) { + return $this->healthyResponse(); + } + + return new Response(200); + }); + + $cache = $this->createMock(CacheInterface::class); + $cache->method('has')->willReturnCallback(static function (string $key) use ($cacheKey): bool { + // app_created flag not set yet; health result not cached. + return false; + }); + $cache->expects($this->atLeastOnce()) + ->method('set'); + + $logger = new SignozServiceLogger($firstHttpClient, $publicKey, 'sandbox', $cache, '1.0.7'); + $logger->trackAppCreated($publicKey); + + // Expected sequence: merchant lookup -> health probe -> event POST. + $this->assertCount(3, $calls); + $this->assertSame(self::MERC_INFO_URL . $publicKey, $calls[0]['uri']); + $this->assertSame(self::HEALTH_URL, $calls[1]['uri']); + $this->assertSame(self::EVENTS_URL, $calls[2]['uri']); + + $this->assertSame('app.created', $calls[2]['options']['json']['name']); + $this->assertSame($publicKey, $calls[2]['options']['json']['data']['public_key']); + $this->assertSame('Bajoski-Software-Developement', $calls[2]['options']['json']['data']['app_id']); + + // Second logger: cache says app.created was already sent -> no HTTP at all. + $this->resetStaticState(); + + $secondHttpClient = $this->createMock(ClientInterface::class); + $secondHttpClient->expects($this->never())->method('request'); + + $secondCache = $this->createMock(CacheInterface::class); + $secondCache->method('has') + ->with($cacheKey) + ->willReturn(true); + $secondCache->expects($this->never())->method('set'); + + $secondLogger = new SignozServiceLogger($secondHttpClient, $publicKey, 'sandbox', $secondCache, '1.0.7'); + $secondLogger->trackAppCreated($publicKey); + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + /** + * Build a ClientInterface mock that records every call and delegates + * the response to $handler. Replaces withConsecutive(), which was + * removed in PHPUnit 10. + * + * @param array $calls + */ + private function mockHttpClient(array &$calls, callable $handler): ClientInterface + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->method('request') + ->willReturnCallback(function (string $method, string $uri, array $options = []) use (&$calls, $handler) { + $calls[] = ['method' => $method, 'uri' => $uri, 'options' => $options]; + + return $handler($method, $uri, $options); + }); + + return $httpClient; + } + + private function makeLogger(ClientInterface $httpClient, ?CacheInterface $cache = null): SignozServiceLogger + { + return new SignozServiceLogger( + $httpClient, + 'FLWPUBK_TEST-0000000000000000000000000000000-X', + 'sandbox', + $cache, + '1.0.7' + ); + } + + private function healthyResponse(): Response + { + return new Response(200, [], json_encode([ + 'status' => 'ok', + 'dependencies' => ['redis' => 'up'], + ])); + } + + private function serviceUnavailableException(): RequestException + { + return new RequestException( + 'Service Unavailable', + new Request('POST', self::EVENTS_URL), + new Response(503) + ); + } + + private function resetStaticState(): void + { + $reflection = new ReflectionClass(SignozServiceLogger::class); + + $defaults = [ + 'appCreatedSent' => false, + 'staticFailureCount' => 0, + 'staticOpenUntil' => 0, + 'staticHealthyUntil' => 0, + ]; + + foreach ($defaults as $name => $value) { + $property = $reflection->getProperty($name); + $property->setAccessible(true); + $property->setValue(null, $value); + } + } + + private function getStaticValue(string $name) { $reflection = new ReflectionClass(SignozServiceLogger::class); - $property = $reflection->getProperty('appCreatedSent'); + $property = $reflection->getProperty($name); $property->setAccessible(true); - $property->setValue(false); + + return $property->getValue(); } -} +} \ No newline at end of file