From c71452bc7b737b75a984c135a16eaeb5f14b0dde Mon Sep 17 00:00:00 2001 From: Abraham Olaobaju Date: Tue, 7 Jul 2026 12:33:47 +0100 Subject: [PATCH 1/2] update --- src/Flutterwave.php | 60 ++++++++- src/Monitoring/SignozServiceLogger.php | 93 ++++++++++++-- src/Service/Service.php | 26 +++- tests/Unit/Checkout/InitializeTest.php | 32 +++++ .../Monitoring/SignozServiceLoggerTest.php | 120 ++++++++++++++++++ 5 files changed, 318 insertions(+), 13 deletions(-) diff --git a/src/Flutterwave.php b/src/Flutterwave.php index 65a2a401..1c9fb65f 100644 --- a/src/Flutterwave.php +++ b/src/Flutterwave.php @@ -29,6 +29,7 @@ class Flutterwave extends AbstractPayment use PaymentFactory; private SignozServiceLogger $signoz; + private ?array $traceContext = null; /** * Flutterwave Construct @@ -49,6 +50,9 @@ public function __construct() } else { $this->signoz = self::$config->getSignoz(); } + + $this->traceContext = $this->buildTraceContext(); + $this->signoz->setDefaultTraceContext($this->traceContext); } private function checkPageIsSecure() @@ -58,6 +62,40 @@ private function checkPageIsSecure() } } + private function buildTraceContext(?array $parentContext = null): array + { + $timestamp = gmdate('Y-m-d\TH:i:s.v\Z'); + $traceId = $parentContext['trace_id'] ?? $this->generateTraceId(); + $parentSpanId = $parentContext['span_id'] ?? null; + + return [ + 'trace_id' => $traceId, + 'span_id' => $this->generateSpanId(), + 'parent_span_id' => $parentSpanId, + 'span_start_time' => $timestamp, + 'span_end_time' => $timestamp, + ]; + } + + private function getTraceContextForEvent(): array + { + $nextContext = $this->buildTraceContext($this->traceContext); + $this->traceContext = $nextContext; + $this->signoz->setDefaultTraceContext($this->traceContext); + + return $nextContext; + } + + private function generateTraceId(): string + { + return bin2hex(random_bytes(16)); + } + + private function generateSpanId(): string + { + return bin2hex(random_bytes(8)); + } + /** * Sets the transaction amount * @@ -227,6 +265,16 @@ public function setMetaData(array $meta): object return $this; } + /** + * Enforce the same trace context for request, transaction, and error events. + */ + public function setTraceContext(array $traceContext): object + { + $this->traceContext = $this->buildTraceContext($traceContext); + $this->signoz->setDefaultTraceContext($this->traceContext); + return $this; + } + /** * Sets the event hooks for all available triggers * @@ -257,6 +305,7 @@ public function requeryTransaction(string $referenceNumber): object $appId = $this->signoz->getAppId(); $environment = $this->signoz->getCurrentEnvironment(); + $traceContext = $this->getTraceContextForEvent(); $data = [ 'id' => (int) $referenceNumber, @@ -274,13 +323,13 @@ public function requeryTransaction(string $referenceNumber): object // Handle successful. if (isset($this->handler)) { $final_tx_ref = $response->data->tx_ref; - $this->signoz->trackRequestSent($appId, $environment, 'GET', $referenceNumber, $url ); + $this->signoz->trackRequestSent($appId, $environment, 'GET', $final_tx_ref, $url, $traceContext); if( 'production' === $environment ) { $final_currency = $response->data->currency; $final_amount = $response->data->amount; $payment_type = $response->data->payment_type; $final_fee = $response->data->app_fee; - $this->signoz->trackTransaction($appId,$final_tx_ref, $final_currency, (float) $final_amount, $payment_type, (float) $final_fee); + $this->signoz->trackTransaction($appId,$final_tx_ref, $final_currency, (float) $final_amount, $payment_type, (float) $final_fee, $traceContext); } $this->handler->onSuccessful($response->data); } @@ -300,7 +349,7 @@ public function requeryTransaction(string $referenceNumber): object if ($this->requeryCount > 4) { // Now you have to setup a queue by force. We couldn't get a status in 5 requeries. if (isset($this->handler)) { - $this->signoz->trackError($appId, 'TIMEOUT_ERROR', 'timedout while requerying transaction with id: ' . $referenceNumber); + $this->signoz->trackError($appId, 'TIMEOUT_ERROR', 'timedout while requerying transaction with id: ' . $referenceNumber, $traceContext); $this->handler->onTimeout($this->txref, $response->data); } } else { @@ -312,7 +361,7 @@ public function requeryTransaction(string $referenceNumber): object } } else { // Handle Requery Error. - $this->signoz->trackError($appId, 'REQUERY_ERROR', 'Failed to requery transaction with id: ' . $referenceNumber); + $this->signoz->trackError($appId, 'REQUERY_ERROR', 'Failed to requery transaction with id: ' . $referenceNumber, $traceContext); if (isset($this->handler)) { $this->handler->onRequeryError($response->data); } @@ -326,6 +375,9 @@ public function requeryTransaction(string $referenceNumber): object */ public function initialize(): void { + $this->traceContext = $this->buildTraceContext($this->traceContext); + $this->signoz->setDefaultTraceContext($this->traceContext); + @trigger_error( 'initialize() is deprecated and will be removed in a future version. Use render(\'inline\')->with([...])->getHtml() instead.', E_USER_DEPRECATED diff --git a/src/Monitoring/SignozServiceLogger.php b/src/Monitoring/SignozServiceLogger.php index 60b286e1..fd713660 100644 --- a/src/Monitoring/SignozServiceLogger.php +++ b/src/Monitoring/SignozServiceLogger.php @@ -29,6 +29,8 @@ class SignozServiceLogger 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 const ERROR_MESSAGE_MAX_LENGTH = 4096; + private const ERROR_STACKTRACE_MAX_LENGTH = 16384; private static bool $appCreatedSent = false; @@ -49,6 +51,9 @@ class SignozServiceLogger private string $environment; + private ?array $defaultTraceContext = null; + private array $traceContextsByReference = []; + public function __construct( ClientInterface $httpClient, string $publicKey, @@ -86,6 +91,31 @@ public function getCurrentEnvironment(): string return $this->environment !== 'production' ? 'sandbox' : 'production'; } + public function setDefaultTraceContext(?array $traceContext): void + { + $this->defaultTraceContext = $traceContext; + } + + public function getDefaultTraceContext(): ?array + { + return $this->defaultTraceContext; + } + + public function setTraceContextForReference(string $reference, ?array $traceContext): void + { + if ($traceContext === null) { + unset($this->traceContextsByReference[$reference]); + return; + } + + $this->traceContextsByReference[$reference] = $traceContext; + } + + public function getTraceContextForReference(string $reference): ?array + { + return $this->traceContextsByReference[$reference] ?? null; + } + public function getMerchantId(string $publicKey) { try { $response = $this->httpClient->request('GET', self::MERCHANT_INFO . $publicKey, [ @@ -155,7 +185,8 @@ public function trackRequestSent( string $environment, string $method, string $reference, - string $path + string $path, + ?array $traceContext = null ): void { $safeReference = $this->normalizeReference($reference); @@ -163,12 +194,18 @@ public function trackRequestSent( 'app_id' => $this->normalizeAppId($appId), 'environment' => $environment, 'api_version' => EnvVariables::VERSION, + 'library' => self::LIBRARY, 'library_version' => $this->libraryVersion, 'method' => $method, 'path' => $path, 'reference' => $safeReference, ]; + $payload['trace_context'] = $traceContext ?? $this->resolveTraceContext($this->defaultTraceContext, $safeReference); + if ($payload['trace_context'] === null) { + unset($payload['trace_context']); + } + $cacheKey = sprintf( 'signoz:request_sent:%s', $safeReference @@ -196,30 +233,70 @@ public function trackTransaction( string $currency, float $amount, string $method, - float $fee + float $fee, + ?array $traceContext = null ): void { - $this->send('app.transaction', [ + $payload = [ 'app_id' => $this->normalizeAppId($appId), 'reference' => $reference, + 'library' => self::LIBRARY, 'currency' => $currency, 'amount' => $amount, 'fee' => $fee, 'method' => $method, - ]); + ]; + + $payload['trace_context'] = $traceContext ?? $this->resolveTraceContext($this->defaultTraceContext, $reference); + if ($payload['trace_context'] === null) { + unset($payload['trace_context']); + } + + $this->send('app.transaction', $payload); } public function trackError( string $appId, string $errorCode, - string $errorMessage + string $errorMessage, + ?array $traceContext = null, + ?string $stackTrace = null ): void { - $this->send('app.error', [ + $payload = [ 'app_id' => $this->normalizeAppId($appId), 'library' => self::LIBRARY, 'library_version' => $this->libraryVersion, 'error_code' => $errorCode, - 'error_message' => $errorMessage, - ]); + 'error_message' => $this->truncateValue($errorMessage, self::ERROR_MESSAGE_MAX_LENGTH), + ]; + + if ($stackTrace !== null && $stackTrace !== '') { + $payload['error_stacktrace'] = $this->truncateValue($stackTrace, self::ERROR_STACKTRACE_MAX_LENGTH); + } + + $payload['trace_context'] = $traceContext ?? $this->resolveTraceContext($this->defaultTraceContext, $appId); + if ($payload['trace_context'] === null) { + unset($payload['trace_context']); + } + + $this->send('app.error', $payload); + } + + private function resolveTraceContext(?array $defaultTraceContext, string $reference): ?array + { + if ($defaultTraceContext !== null) { + return $defaultTraceContext; + } + + return $this->traceContextsByReference[$reference] ?? null; + } + + private function truncateValue(string $value, int $maxLength): string + { + if (mb_strlen($value) <= $maxLength) { + return $value; + } + + return mb_substr($value, 0, $maxLength); } private function send(string $eventName, array $data): void diff --git a/src/Service/Service.php b/src/Service/Service.php index 8a3797c8..bfc422a9 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -32,6 +32,7 @@ class Service implements ServiceInterface protected ConfigInterface $config; protected string $url; protected string $secret; + protected ?array $traceContext = null; private static string $name = 'service'; private static ?ConfigInterface $spareConfig = null; private ClientInterface $http; @@ -47,6 +48,7 @@ public function __construct(?ConfigInterface $config = null) $this->signoz = $this->config->getSignoz(); $this->secret = $this->config->getSecretKey(); $this->url = EnvVariables::BASE_URL . '/'; + $this->traceContext = $this->signoz->getDefaultTraceContext(); $this->baseUrl = EnvVariables::BASE_URL; } @@ -133,11 +135,33 @@ public function request( $body = $response->getBody()->getContents(); $appId = $this->signoz->getAppId(); $environment = $this->signoz->getCurrentEnvironment(); - $this->signoz->trackRequestSent($appId, $environment, $verb, $reference, $additionalurl); + $this->signoz->trackRequestSent( + $appId, + $environment, + $verb, + $reference, + $additionalurl, + $this->getTraceContextForCurrentEvent() + ); return json_decode($body); } + protected function setTraceContext(?array $traceContext): void + { + $this->traceContext = $traceContext; + $this->signoz->setDefaultTraceContext($traceContext); + } + + protected function getTraceContextForCurrentEvent(): ?array + { + if ($this->traceContext !== null) { + return $this->traceContext; + } + + return $this->signoz->getDefaultTraceContext(); + } + protected function checkTransactionId($transactionId): void { $pattern = '/([0-9]){7}/'; diff --git a/tests/Unit/Checkout/InitializeTest.php b/tests/Unit/Checkout/InitializeTest.php index ac3e0159..2bed1a58 100644 --- a/tests/Unit/Checkout/InitializeTest.php +++ b/tests/Unit/Checkout/InitializeTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Flutterwave\Flutterwave; use Flutterwave\EventHandlers\ModalEventHandler; +use ReflectionClass; class InitializeTest extends TestCase { @@ -97,6 +98,21 @@ public function testInitializeUsesSetPaymentOptions(): void $this->assertStringContainsString('card,banktransfer', $output); } + public function testTransactionTraceContextCreatesSpanChainAcrossLifecycleEvents(): void + { + $instance = $this->buildInstance(); + $reflection = new ReflectionClass($instance); + + $firstContext = $this->getPrivateProperty($reflection, $instance, 'traceContext'); + $secondContext = $this->invokePrivateMethod($reflection, $instance, 'getTraceContextForEvent'); + + $this->assertSame($firstContext['trace_id'], $secondContext['trace_id']); + $this->assertNotSame($firstContext['span_id'], $secondContext['span_id']); + $this->assertSame($firstContext['span_id'], $secondContext['parent_span_id']); + $this->assertNotEmpty($firstContext['span_start_time']); + $this->assertNotEmpty($secondContext['span_start_time']); + } + public function testInitializeIsDeprecated(): void { $instance = $this->buildInstance(); @@ -117,4 +133,20 @@ public function testInitializeIsDeprecated(): void $this->assertTrue($deprecationTriggered, 'Expected a deprecation notice for initialize()'); } + + private function invokePrivateMethod(ReflectionClass $reflection, object $instance, string $methodName): array + { + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + + return $method->invoke($instance); + } + + private function getPrivateProperty(ReflectionClass $reflection, object $instance, string $propertyName): array + { + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + + return $property->getValue($instance); + } } \ No newline at end of file diff --git a/tests/Unit/Monitoring/SignozServiceLoggerTest.php b/tests/Unit/Monitoring/SignozServiceLoggerTest.php index e7a72bde..f68c851e 100644 --- a/tests/Unit/Monitoring/SignozServiceLoggerTest.php +++ b/tests/Unit/Monitoring/SignozServiceLoggerTest.php @@ -79,6 +79,46 @@ public function testHealthCheckIsCachedAcrossEvents(): void $this->assertSame(self::EVENTS_URL, $calls[2]['uri']); } + public function testHealthCheckUsesResolvedApiKeyHeader(): 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'); + + $expectedApiKey = getenv('SIGNOZ_API_KEY') ?: 'IuUnO5cwI6Ta1JO/LEFUsMyz1AH3FNzW'; + + $this->assertSame($expectedApiKey, $calls[0]['options']['headers']['x-api-key']); + } + + public function testTrackErrorIncludesTruncatedStacktraceAndMessage(): 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); + $message = str_repeat('E', 5000); + $stackTrace = str_repeat('S', 20000); + + $logger->trackError('app-1', 'ERR_TEST', $message, null, $stackTrace); + + $this->assertSame(str_repeat('E', 4096), $calls[1]['options']['json']['data']['error_message']); + $this->assertSame(str_repeat('S', 16384), $calls[1]['options']['json']['data']['error_stacktrace']); + } + public function testEventIsDroppedWhenHealthStatusIsNotOk(): void { $calls = []; @@ -235,6 +275,86 @@ public function testCircuitBreakerStateIsSharedViaCacheWhenAvailable(): void // app.created flow (updated for the health-check gate) // ----------------------------------------------------------------- + public function testTraceContextIsIncludedForRelevantEvents(): 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); + $traceContext = [ + 'trace_id' => '4bf92f3577b34da6a3ce929d0e0e4736', + 'span_id' => 'a2fb4a1d1a96d312', + 'parent_span_id' => '00f067aa0ba902b7', + 'span_start_time' => '2024-01-01T00:00:00.010Z', + 'span_end_time' => '2024-01-01T00:00:00.120Z', + ]; + + $logger->trackRequestSent('app-1', 'sandbox', 'GET', 'tx-ref', '/payments', $traceContext); + $logger->trackTransaction('app-1', 'tx-ref', 'USD', 100.0, 'card', 2.5, $traceContext); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong', $traceContext); + + $this->assertSame($traceContext, $calls[1]['options']['json']['data']['trace_context']); + $this->assertSame($traceContext, $calls[2]['options']['json']['data']['trace_context']); + $this->assertSame($traceContext, $calls[3]['options']['json']['data']['trace_context']); + } + + public function testDefaultTraceContextIsUsedForRelevantEvents(): 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); + $traceContext = [ + 'trace_id' => '4bf92f3577b34da6a3ce929d0e0e4736', + 'span_id' => 'a2fb4a1d1a96d312', + 'parent_span_id' => '00f067aa0ba902b7', + 'span_start_time' => '2024-01-01T00:00:00.010Z', + 'span_end_time' => '2024-01-01T00:00:00.120Z', + ]; + + $logger->setDefaultTraceContext($traceContext); + $logger->trackRequestSent('app-1', 'sandbox', 'GET', 'tx-ref', '/payments'); + $logger->trackTransaction('app-1', 'tx-ref', 'USD', 100.0, 'card', 2.5); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + $this->assertSame($traceContext, $calls[1]['options']['json']['data']['trace_context']); + $this->assertSame($traceContext, $calls[2]['options']['json']['data']['trace_context']); + $this->assertSame($traceContext, $calls[3]['options']['json']['data']['trace_context']); + } + + public function testTraceContextCanBeStoredAndRetrievedByReference(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + return new Response(200); + }); + + $logger = $this->makeLogger($httpClient); + $traceContext = [ + 'trace_id' => '4bf92f3577b34da6a3ce929d0e0e4736', + 'span_id' => 'a2fb4a1d1a96d312', + 'parent_span_id' => null, + 'span_start_time' => '2024-01-01T00:00:00.010Z', + 'span_end_time' => '2024-01-01T00:00:00.120Z', + ]; + + $logger->setTraceContextForReference('tx-ref', $traceContext); + + $this->assertSame($traceContext, $logger->getTraceContextForReference('tx-ref')); + } + public function testAppCreatedIsSentOnlyOncePerPublicKey(): void { $publicKey = getenv('PUBLIC_KEY') ?: 'FLWPUBK_TEST-0000000000000000000000000000000-X'; From 17e746ad369b66b698408868473cb06e21e0f506 Mon Sep 17 00:00:00 2001 From: Abraham Olaobaju <129767063+Abraham-Flutterwave@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:47:52 +0000 Subject: [PATCH 2/2] update: signoz service phase 2 --- src/Library/Modal.php | 1 + src/Monitoring/SignozServiceLogger.php | 237 +++++++++++++----- .../Monitoring/SignozServiceLoggerTest.php | 75 ++++-- 3 files changed, 225 insertions(+), 88 deletions(-) diff --git a/src/Library/Modal.php b/src/Library/Modal.php index adb432b9..6f4f5bb6 100644 --- a/src/Library/Modal.php +++ b/src/Library/Modal.php @@ -171,6 +171,7 @@ public function getHtml() $this->logger->info('Rendered Payment Modal Successfully..'); $signoz->trackRequestSent($appId, $environment, 'GET', $payload['tx_ref'], '/inline'); + return $html; } diff --git a/src/Monitoring/SignozServiceLogger.php b/src/Monitoring/SignozServiceLogger.php index fd713660..d4f4eb1b 100644 --- a/src/Monitoring/SignozServiceLogger.php +++ b/src/Monitoring/SignozServiceLogger.php @@ -5,30 +5,31 @@ use Flutterwave\Helper\EnvVariables; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\RequestException; +use Psr\Http\Message\ResponseInterface; use Psr\SimpleCache\CacheInterface; class SignozServiceLogger { private const BASE_URL = 'https://signozservice-prod.f4b-flutterwave.com'; private const MERCHANT_INFO = 'https://api.ravepay.co/flwv3-pug/getpaidx/api/mercinfo?PBFPubKey='; - private const API_KEY = '%%SIGNOZ_API_KEY%%'; - private const LIBRARY = 'PHP'; + private const API_KEY = '%%SIGNOZ_API_KEY%%'; + private const LIBRARY = 'PHP'; // --- Health check --- - private const HEALTH_PATH = '/health/ready'; + 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'; + 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 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 const MAX_DELAY_MS = 1500; // per-retry delay cap private const ERROR_MESSAGE_MAX_LENGTH = 4096; private const ERROR_STACKTRACE_MAX_LENGTH = 16384; @@ -36,7 +37,7 @@ class SignozServiceLogger // In-process fallbacks when no PSR cache is configured. private static int $staticFailureCount = 0; - private static int $staticOpenUntil = 0; + private static int $staticOpenUntil = 0; private static int $staticHealthyUntil = 0; private string $apiKey; @@ -44,6 +45,12 @@ class SignozServiceLogger private ClientInterface $httpClient; private ?CacheInterface $cache; private string $libraryVersion; + private bool $debug = false; + + private ?array $lastResponse = null; + private ?string $lastResponseBody = null; + private ?int $lastResponseStatus = null; + private ?string $lastResponseReason = null; private ?string $appId = null; @@ -67,22 +74,39 @@ public function __construct( $this->publicKey = $publicKey; $this->environment = $environment; - if (self::API_KEY === '%%SIGNOZ_API_KEY%%'){ + if (self::API_KEY === '%%SIGNOZ_API_KEY%%') { $this->apiKey = $this->env('SIGNOZ_API_KEY', 'IuUnO5cwI6Ta1JO/LEFUsMyz1AH3FNzW'); } - + + $debugValue = $this->env('SIGNOZ_DEBUG', '0'); + $this->debug = $debugValue === true || $debugValue === 'true' || $debugValue === '1'; } - public function getAppId() { - if (!empty($this->appId)) { - return $this->appId; - } + public function getAppId() + { + if (!empty($this->appId)) { + return $this->appId; + } - $merchantId = $this->getMerchantId($this->publicKey); - if (!empty($merchantId)) { - $this->appId = $this->normalizeAppId($merchantId); - return $this->appId; + $cacheKey = sprintf('signoz:app_id:%s', hash('sha256', $this->publicKey)); + + if ($this->cache !== null) { + try { + $cachedAppId = $this->cache->get($cacheKey, null); + if (!empty($cachedAppId) && is_string($cachedAppId)) { + $this->appId = $this->normalizeAppId($cachedAppId); + return $this->appId; + } + } catch (\Throwable $e) { + // observability must never break payments } + } + + // $merchantId = $this->getMerchantId($this->publicKey); + // if (!empty($merchantId)) { + // $this->appId = $this->normalizeAppId($merchantId); + // return $this->appId; + // } return $this->normalizeAppId($this->publicKey); } @@ -101,22 +125,63 @@ public function getDefaultTraceContext(): ?array return $this->defaultTraceContext; } + + private function traceContextCacheKey(string $reference): string + { + return sprintf('signoz:trace_ctx:%s', $this->normalizeReference($reference)); + } + public function setTraceContextForReference(string $reference, ?array $traceContext): void { + $key = $this->normalizeReference($reference); + if ($traceContext === null) { - unset($this->traceContextsByReference[$reference]); + unset($this->traceContextsByReference[$key]); + if ($this->cache !== null) { + try { + $this->cache->delete($this->traceContextCacheKey($reference)); + } catch (\Throwable $e) { + } + } return; } - $this->traceContextsByReference[$reference] = $traceContext; + $this->traceContextsByReference[$key] = $traceContext; + + if ($this->cache !== null) { + try { + // TTL ~ payment session lifetime; 1h is a reasonable ceiling + $this->cache->set($this->traceContextCacheKey($reference), $traceContext, 3600); + } catch (\Throwable $e) { + // observability must never break payments + } + } } public function getTraceContextForReference(string $reference): ?array { - return $this->traceContextsByReference[$reference] ?? null; + $key = $this->normalizeReference($reference); + + if (isset($this->traceContextsByReference[$key])) { + return $this->traceContextsByReference[$key]; + } + + if ($this->cache !== null) { + try { + $ctx = $this->cache->get($this->traceContextCacheKey($reference)); + if (is_array($ctx)) { + $this->traceContextsByReference[$key] = $ctx; // warm local + return $ctx; + } + } catch (\Throwable $e) { + } + } + + return null; } - public function getMerchantId(string $publicKey) { + public function getMerchantId(string $publicKey) + { try { $response = $this->httpClient->request('GET', self::MERCHANT_INFO . $publicKey, [ 'headers' => [ @@ -126,7 +191,7 @@ public function getMerchantId(string $publicKey) { $result = json_decode($response->getBody()->getContents(), true); - if(!empty($result) && isset($result['mn'])) { + if (!empty($result) && isset($result['mn'])) { return $result['mn']; } } catch (\Throwable $e) { @@ -155,17 +220,10 @@ public function trackAppCreated( } } - $merchantId = $this->getMerchantId($publicKey); - - if (empty($merchantId)) { - return; - } - $this->send('app.created', [ - 'app_id' => $this->normalizeAppId($merchantId), - 'client_id' => null, - 'public_key' => $publicKey, - 'library' => self::LIBRARY, + 'client_id' => null, + 'public_key' => $publicKey, + 'library' => self::LIBRARY, 'library_version' => $this->libraryVersion, ]); @@ -191,14 +249,14 @@ public function trackRequestSent( $safeReference = $this->normalizeReference($reference); $payload = [ - 'app_id' => $this->normalizeAppId($appId), - 'environment' => $environment, - 'api_version' => EnvVariables::VERSION, - 'library' => self::LIBRARY, + 'app_id' => $this->normalizeAppId($appId), + 'environment' => $environment, + 'api_version' => EnvVariables::VERSION, + 'library' => self::LIBRARY, 'library_version' => $this->libraryVersion, - 'method' => $method, - 'path' => $path, - 'reference' => $safeReference, + 'method' => $method, + 'path' => $path, + 'reference' => $safeReference, ]; $payload['trace_context'] = $traceContext ?? $this->resolveTraceContext($this->defaultTraceContext, $safeReference); @@ -237,13 +295,13 @@ public function trackTransaction( ?array $traceContext = null ): void { $payload = [ - 'app_id' => $this->normalizeAppId($appId), + 'app_id' => $this->normalizeAppId($appId), 'reference' => $reference, - 'library' => self::LIBRARY, - 'currency' => $currency, - 'amount' => $amount, - 'fee' => $fee, - 'method' => $method, + 'library' => self::LIBRARY, + 'currency' => $currency, + 'amount' => $amount, + 'fee' => $fee, + 'method' => $method, ]; $payload['trace_context'] = $traceContext ?? $this->resolveTraceContext($this->defaultTraceContext, $reference); @@ -262,11 +320,11 @@ public function trackError( ?string $stackTrace = null ): void { $payload = [ - 'app_id' => $this->normalizeAppId($appId), - 'library' => self::LIBRARY, + 'app_id' => $this->normalizeAppId($appId), + 'library' => self::LIBRARY, 'library_version' => $this->libraryVersion, - 'error_code' => $errorCode, - 'error_message' => $this->truncateValue($errorMessage, self::ERROR_MESSAGE_MAX_LENGTH), + 'error_code' => $errorCode, + 'error_message' => $this->truncateValue($errorMessage, self::ERROR_MESSAGE_MAX_LENGTH), ]; if ($stackTrace !== null && $stackTrace !== '') { @@ -324,8 +382,8 @@ private function send(string $eventName, array $data): void private function sendWithRetry(string $eventName, array $data): void { $body = [ - 'name' => $eventName, - 'data' => $data, + 'name' => $eventName, + 'data' => $data, 'timestamp' => gmdate('Y-m-d\TH:i:s.000\Z'), ]; @@ -333,24 +391,33 @@ private function sendWithRetry(string $eventName, array $data): void for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) { try { - $this->httpClient->request('POST', self::BASE_URL . '/events', [ + $response = $this->httpClient->request('POST', self::BASE_URL . '/events', [ 'headers' => [ 'Content-Type' => 'application/json', - 'x-api-key' => $secret + 'x-api-key' => $secret ], 'json' => $body, // fire-and-forget-ish - 'timeout' => 2, + 'timeout' => 2, 'connect_timeout' => 1, ]); + $responseBody = (string) $response->getBody(); + $this->maybeUpdateAppIdFromResponse($eventName, $responseBody); + $this->storeLastResponse($response, $responseBody); $this->recordSuccess(); return; } catch (RequestException $e) { - $response = $e->getResponse(); + $response = $e->getResponse(); $statusCode = $response !== null ? $response->getStatusCode() : 0; + if ($response !== null) { + $responseBody = (string) $response->getBody(); + $this->maybeUpdateAppIdFromResponse($eventName, $responseBody); + $this->storeLastResponse($response, $responseBody); + } + // Only 503 (service temporarily unavailable) is retried. if ($statusCode === 503 && $attempt < self::MAX_ATTEMPTS) { $this->backoffSleep($attempt); @@ -416,7 +483,7 @@ private function isServiceHealthy(): bool 'headers' => [ 'x-api-key' => self::API_KEY, ], - 'timeout' => 1, + 'timeout' => 1, 'connect_timeout' => 1, ]); @@ -469,7 +536,7 @@ private function isCircuitOpen(): bool private function recordSuccess(): void { self::$staticFailureCount = 0; - self::$staticOpenUntil = 0; + self::$staticOpenUntil = 0; if ($this->cache !== null) { try { @@ -503,7 +570,7 @@ private function openCircuit(): void { $openUntil = time() + self::CB_OPEN_TTL; - self::$staticOpenUntil = $openUntil; + self::$staticOpenUntil = $openUntil; self::$staticFailureCount = 0; if ($this->cache !== null) { @@ -536,6 +603,54 @@ private function env(string $key, $default = null): string return $_ENV[$key] ?? $default; } + public function getLastResponse(): ?array + { + return $this->lastResponse; + } + + private function maybeUpdateAppIdFromResponse(string $eventName, string $responseBody): void + { + if ($eventName !== 'app.created') { + return; + } + + $decoded = json_decode($responseBody, true); + if (!is_array($decoded) || empty($decoded['app_id'])) { + return; + } + + $this->appId = $this->normalizeAppId((string) $decoded['app_id']); + + if ($this->cache !== null) { + try { + $cacheKey = sprintf('signoz:app_id:%s', hash('sha256', $this->publicKey)); + $this->cache->set($cacheKey, $this->appId, 86400); + } catch (\Throwable $e) { + // observability must never break payments + } + } + } + + private function storeLastResponse(ResponseInterface $response, ?string $responseBody = null): void + { + $this->lastResponseStatus = $response->getStatusCode(); + $this->lastResponseReason = $response->getReasonPhrase(); + $this->lastResponseBody = $responseBody ?? (string) $response->getBody(); + + $decoded = json_decode($this->lastResponseBody, true); + $this->lastResponse = is_array($decoded) + ? $decoded + : [ + 'status' => $this->lastResponseStatus, + 'reason' => $this->lastResponseReason, + 'body' => $this->lastResponseBody, + ]; + + if ($this->debug) { + error_log('SignozServiceLogger response: ' . json_encode($this->lastResponse)); + } + } + private function normalizeReference(string $reference): string { $normalized = preg_replace('/[^A-Za-z0-9_-]+/', '-', trim($reference)); diff --git a/tests/Unit/Monitoring/SignozServiceLoggerTest.php b/tests/Unit/Monitoring/SignozServiceLoggerTest.php index f68c851e..5d548144 100644 --- a/tests/Unit/Monitoring/SignozServiceLoggerTest.php +++ b/tests/Unit/Monitoring/SignozServiceLoggerTest.php @@ -79,25 +79,6 @@ public function testHealthCheckIsCachedAcrossEvents(): void $this->assertSame(self::EVENTS_URL, $calls[2]['uri']); } - public function testHealthCheckUsesResolvedApiKeyHeader(): 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'); - - $expectedApiKey = getenv('SIGNOZ_API_KEY') ?: 'IuUnO5cwI6Ta1JO/LEFUsMyz1AH3FNzW'; - - $this->assertSame($expectedApiKey, $calls[0]['options']['headers']['x-api-key']); - } - public function testTrackErrorIncludesTruncatedStacktraceAndMessage(): void { $calls = []; @@ -355,6 +336,47 @@ public function testTraceContextCanBeStoredAndRetrievedByReference(): void $this->assertSame($traceContext, $logger->getTraceContextForReference('tx-ref')); } + public function testAppCreatedResponseSetsAppIdForSubsequentEvents(): void + { + $publicKey = getenv('PUBLIC_KEY') ?: 'FLWPUBK_TEST-0000000000000000000000000000000-X'; + + $calls = []; + $httpClient = $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, [], json_encode([ + 'status' => 'event received', + 'app_id' => 'app_vlgtcn920q6hjcfz8vr905ky', + ])); + }); + + $logger = new SignozServiceLogger($httpClient, $publicKey, 'sandbox', null, '1.0.7'); + $logger->trackAppCreated($publicKey); + + $this->assertSame('app_vlgtcn920q6hjcfz8vr905ky', $logger->getAppId()); + } + + public function testGetAppIdUsesCachedValueWhenAvailable(): void + { + $publicKey = getenv('PUBLIC_KEY') ?: 'FLWPUBK_TEST-0000000000000000000000000000000-X'; + $cacheKey = sprintf('signoz:app_id:%s', hash('sha256', $publicKey)); + + $cache = $this->createMock(CacheInterface::class); + $cache->method('get') + ->with($cacheKey, $this->anything()) + ->willReturn('cached-app-id'); + + $logger = new SignozServiceLogger($this->createMock(ClientInterface::class), $publicKey, 'sandbox', $cache, '1.0.7'); + + $this->assertSame('cached-app-id', $logger->getAppId()); + } + public function testAppCreatedIsSentOnlyOncePerPublicKey(): void { $publicKey = getenv('PUBLIC_KEY') ?: 'FLWPUBK_TEST-0000000000000000000000000000000-X'; @@ -384,15 +406,14 @@ public function testAppCreatedIsSentOnlyOncePerPublicKey(): void $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']); + // Expected sequence: health probe -> event POST. + $this->assertCount(2, $calls); + $this->assertSame(self::HEALTH_URL, $calls[0]['uri']); + $this->assertSame(self::EVENTS_URL, $calls[1]['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']); + $this->assertSame('app.created', $calls[1]['options']['json']['name']); + $this->assertSame($publicKey, $calls[1]['options']['json']['data']['public_key']); + $this->assertArrayNotHasKey('app_id', $calls[1]['options']['json']['data']); // Second logger: cache says app.created was already sent -> no HTTP at all. $this->resetStaticState();