diff --git a/lib/Checkout/AbstractCheckoutSdkBuilder.php b/lib/Checkout/AbstractCheckoutSdkBuilder.php index 2769b4f..738bfbd 100644 --- a/lib/Checkout/AbstractCheckoutSdkBuilder.php +++ b/lib/Checkout/AbstractCheckoutSdkBuilder.php @@ -13,12 +13,14 @@ abstract class AbstractCheckoutSdkBuilder protected $environmentSubdomain; protected $httpClientBuilder; protected $logger; + protected $enableTelemetry; public function __construct() { $this->environment = Environment::sandbox(); $this->httpClientBuilder = new DefaultHttpClientBuilder([]); $this->setDefaultLogger(); + $this->enableTelemetry = true; } /** @@ -50,6 +52,15 @@ public function httpClientBuilder(HttpClientBuilderInterface $httpClientBuilder) $this->httpClientBuilder = $httpClientBuilder; return $this; } + /** + * @param bool $enableTelemetry + * @return $this + */ + public function enableTelemetry($enableTelemetry) + { + $this->enableTelemetry = $enableTelemetry; + return $this; + } /** * @param LoggerInterface $logger @@ -70,7 +81,8 @@ protected function getCheckoutConfiguration() $this->getSdkCredentials(), $this->environment, $this->httpClientBuilder, - $this->logger + $this->logger, + $this->enableTelemetry ); } diff --git a/lib/Checkout/ApiClient.php b/lib/Checkout/ApiClient.php index b476279..18e3ddf 100644 --- a/lib/Checkout/ApiClient.php +++ b/lib/Checkout/ApiClient.php @@ -3,7 +3,9 @@ namespace Checkout; use Checkout\Common\AbstractQueryFilter; +use Checkout\Common\RequestMetrics; use Checkout\Files\FileRequest; +use Checkout\Common\TelemetryQueue; use Exception; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Response; @@ -21,6 +23,10 @@ class ApiClient private $baseUri; + private $enableTelemetry; + + private $requestMetricsQueue; + public function __construct(CheckoutConfiguration $configuration, $baseUri = null) { $this->configuration = $configuration; @@ -28,6 +34,8 @@ public function __construct(CheckoutConfiguration $configuration, $baseUri = nul $this->jsonSerializer = new JsonSerializer(); $this->logger = $this->configuration->getLogger(); $this->baseUri = $baseUri != null ? $baseUri : $this->configuration->getEnvironment()->getBaseUri(); + $this->enableTelemetry = $this->configuration->isEnableTelemetry(); + $this->requestMetricsQueue = new TelemetryQueue(); } /** @@ -133,31 +141,49 @@ public function submitFileFilesApi($path, FileRequest $fileRequest, SdkAuthoriza } /** + * Summary of handleRequest + * @param $method * @param $path - * @param FileRequest $fileRequest - * @param SdkAuthorization $authorization - * @param $multipart + * @param \Checkout\SdkAuthorization $authorization + * @param array $requestOptions + * @throws \Checkout\CheckoutApiException * @return array - * @throws CheckoutApiException */ - private function submit($path, FileRequest $fileRequest, SdkAuthorization $authorization, $multipart) + private function handleRequest($method, $path, SdkAuthorization $authorization, array $requestOptions) { try { - $this->logger->info("POST " . $path . " file: " . $fileRequest->file); - $headers = $this->getHeaders($authorization, null, null); - $response = $this->client->request("POST", $this->getRequestUrl($path), [ - "verify" => false, - "headers" => $headers, - "multipart" => [ - [ - "name" => $multipart, - "contents" => fopen($fileRequest->file, "r") - ], - [ - "name" => "purpose", - "contents" => $fileRequest->purpose - ] - ]]); + $headers = $requestOptions['headers']; + + if ($this->enableTelemetry) { + $currentRequestId = uniqid(); + $lastRequestMetric = new RequestMetrics(); + $dequeuedMetric = $this->requestMetricsQueue->dequeue(); + + + if ($dequeuedMetric !== null) { + $lastRequestMetric->requestId = $currentRequestId; + $headers["Cko-Sdk-Telemetry"] = base64_encode(json_encode([ + 'prev-request-id' => $lastRequestMetric->prevRequestId, + 'prev-request-duration' => $lastRequestMetric->prevRequestDuration + ])); + } + + $startTime = microtime(true); + $response = $this->client->request($method, $this->getRequestUrl($path), array_merge( + $requestOptions, + ['headers' => $headers] + )); + $duration = (int)((microtime(true) - $startTime) * 1000); + + $lastRequestMetric->prevRequestDuration = $duration; + $lastRequestMetric->prevRequestId = $currentRequestId; + $this->requestMetricsQueue->enqueue($lastRequestMetric); + } else { + $response = $this->client->request($method, $this->getRequestUrl($path), array_merge( + $requestOptions, + ['headers' => $headers] + )); + } return $this->getResponseContents($response); } catch (Exception $e) { $this->logger->error($path . " error: " . $e->getMessage()); @@ -168,6 +194,38 @@ private function submit($path, FileRequest $fileRequest, SdkAuthorization $autho } } + /** + * @param $path + * @param FileRequest $fileRequest + * @param SdkAuthorization $authorization + * @param $multipart + * @return array + * @throws CheckoutApiException + */ + private function submit($path, FileRequest $fileRequest, SdkAuthorization $authorization, $multipart) + { + // Keep specific logging in submit + $this->logger->info("POST " . $path . " file: " . $fileRequest->file); + + $headers = $this->getHeaders($authorization, null, null, null); + $requestOptions = [ + "verify" => false, + "headers" => $headers, + "multipart" => [ + [ + "name" => $multipart, + "contents" => fopen($fileRequest->file, "r") + ], + [ + "name" => "purpose", + "contents" => $fileRequest->purpose + ] + ] + ]; + + return $this->handleRequest("POST", $path, $authorization, $requestOptions); + } + /** * @param string $method * @param string $path @@ -179,22 +237,17 @@ private function submit($path, FileRequest $fileRequest, SdkAuthorization $autho */ private function invoke($method, $path, $body, SdkAuthorization $authorization, $idempotencyKey = null) { - try { - $this->logger->info($method . " " . $path); - $headers = $this->getHeaders($authorization, "application/json", $idempotencyKey); - $response = $this->client->request($method, $this->getRequestUrl($path), [ - "verify" => false, - "body" => $body, - "headers" => $headers - ]); - return $this->getResponseContents($response); - } catch (Exception $e) { - $this->logger->error($path . " error: " . $e->getMessage()); - if ($e instanceof RequestException) { - throw CheckoutApiException::from($e); - } - throw new CheckoutApiException($e); - } + // Keep specific logging in invoke + $this->logger->info($method . " " . $path); + + $headers = $this->getHeaders($authorization, "application/json", $idempotencyKey, null); + $requestOptions = [ + "verify" => false, + "body" => $body, + "headers" => $headers + ]; + + return $this->handleRequest($method, $path, $authorization, $requestOptions); } /** @@ -210,10 +263,11 @@ private function getRequestUrl($path) * @param SdkAuthorization $authorization * @param string|null $contentType * @param string|null $idempotencyKey + * @param string|null $telemetryData * @return array * @throws CheckoutAuthorizationException */ - private function getHeaders(SdkAuthorization $authorization, $contentType, $idempotencyKey) + private function getHeaders(SdkAuthorization $authorization, $contentType, $idempotencyKey, $telemetryData) { $headers = [ "User-agent" => CheckoutUtils::PROJECT_NAME . "/" . CheckoutUtils::PROJECT_VERSION, @@ -226,6 +280,9 @@ private function getHeaders(SdkAuthorization $authorization, $contentType, $idem if (!empty($idempotencyKey)) { $headers["Cko-Idempotency-Key"] = $idempotencyKey; } + if (!empty($telemetryData)) { + $headers["Cko-Sdk-Telemetry"] = base64_encode(json_encode($telemetryData)); + } return $headers; } diff --git a/lib/Checkout/CheckoutConfiguration.php b/lib/Checkout/CheckoutConfiguration.php index 20cc0e3..b44f5e6 100644 --- a/lib/Checkout/CheckoutConfiguration.php +++ b/lib/Checkout/CheckoutConfiguration.php @@ -15,24 +15,29 @@ final class CheckoutConfiguration private $httpClientBuilder; private $logger; + + private $enableTelemetry; /** * @param SdkCredentialsInterface $sdkCredentials * @param Environment $environment * @param HttpClientBuilderInterface $httpClientBuilder * @param LoggerInterface $logger + * @param bool $enableTelemetry */ public function __construct( SdkCredentialsInterface $sdkCredentials, Environment $environment, HttpClientBuilderInterface $httpClientBuilder, - LoggerInterface $logger + LoggerInterface $logger, + $enableTelemetry = true ) { $this->sdkCredentials = $sdkCredentials; $this->environment = $environment; $this->httpClientBuilder = $httpClientBuilder; $this->logger = $logger; $this->environmentSubdomain = null; + $this ->enableTelemetry = $enableTelemetry; } /** @@ -79,4 +84,12 @@ public function getLogger() { return $this->logger; } + + /** + * @return bool + */ + public function isEnableTelemetry() + { + return $this->enableTelemetry; + } } diff --git a/lib/Checkout/CheckoutOAuthSdkBuilder.php b/lib/Checkout/CheckoutOAuthSdkBuilder.php index a180816..4bc0191 100644 --- a/lib/Checkout/CheckoutOAuthSdkBuilder.php +++ b/lib/Checkout/CheckoutOAuthSdkBuilder.php @@ -84,8 +84,10 @@ public function build() $this->getSdkCredentials(), $this->environment, $this->httpClientBuilder, - $this->logger + $this->logger, + $this->enableTelemetry ); + // $configuration = $this->getCheckoutConfiguration(); if ($this->environmentSubdomain !== null) { $configuration->setEnvironmentSubdomain($this->environmentSubdomain); } diff --git a/lib/Checkout/CheckoutStaticKeysSdkBuilder.php b/lib/Checkout/CheckoutStaticKeysSdkBuilder.php index ec8b6aa..d0dc639 100644 --- a/lib/Checkout/CheckoutStaticKeysSdkBuilder.php +++ b/lib/Checkout/CheckoutStaticKeysSdkBuilder.php @@ -48,8 +48,10 @@ public function build() $this->getSdkCredentials(), $this->environment, $this->httpClientBuilder, - $this->logger + $this->logger, + $this->enableTelemetry ); + // $configuration = $this->getCheckoutConfiguration(); if ($this->environmentSubdomain !== null) { $configuration->setEnvironmentSubdomain($this->environmentSubdomain); } diff --git a/lib/Checkout/Common/RequestMetrics.php b/lib/Checkout/Common/RequestMetrics.php new file mode 100644 index 0000000..8d95ccf --- /dev/null +++ b/lib/Checkout/Common/RequestMetrics.php @@ -0,0 +1,17 @@ +prevRequestId = $prevRequestId; + $this->requestId = $requestId; + $this->prevRequestDuration = $prevRequestDuration; + } +} diff --git a/lib/Checkout/Common/TelemetryQueue.php b/lib/Checkout/Common/TelemetryQueue.php new file mode 100644 index 0000000..a20f6a2 --- /dev/null +++ b/lib/Checkout/Common/TelemetryQueue.php @@ -0,0 +1,48 @@ +queue = []; + $this->mutex = fopen(sys_get_temp_dir() . '/telemetry_queue.lock', 'w+'); + } + + public function __destruct() + { + if ($this->mutex) { + fclose($this->mutex); + } + } + + public function enqueue($metrics) + { + flock($this->mutex, LOCK_EX); + try { + if (count($this->queue) < self::MAX_COUNT_IN_TELEMETRY_QUEUE) { + $this->queue[] = $metrics; + } + } finally { + flock($this->mutex, LOCK_UN); + } + } + + public function dequeue() + { + flock($this->mutex, LOCK_EX); + try { + if (empty($this->queue)) { + return null; + } + return array_shift($this->queue); + } finally { + flock($this->mutex, LOCK_UN); + } + } +} diff --git a/lib/Checkout/OAuthSdkCredentials.php b/lib/Checkout/OAuthSdkCredentials.php index 85e2147..9b18872 100644 --- a/lib/Checkout/OAuthSdkCredentials.php +++ b/lib/Checkout/OAuthSdkCredentials.php @@ -85,10 +85,14 @@ public function getAuthorization($authorizationType) */ private function getAccessToken() { + printf("ClientId: %s\n", $this->clientId); + printf("ClientSecret: %s\n", $this->clientSecret); + if (!is_null($this->accessToken) && $this->accessToken->isValid()) { return $this->accessToken; } try { + printf("Scope string: %s\n", implode(" ", $this->scopes)); $response = $this->client->request("POST", $this->authorizationUri, [ "verify" => false, "headers" => [ @@ -101,7 +105,10 @@ private function getAccessToken() "scope" => implode(" ", $this->scopes) ] ]); + printf($response); $body = json_decode($response->getBody(), true); + printf("This is a body!"); + print_r($body); $expirationDate = new DateTime(); $expirationDate->add(new DateInterval("PT" . $body["expires_in"] . "S")); $this->accessToken = new OAuthAccessToken($body["access_token"], $expirationDate); diff --git a/lib/Checkout/Previous/CheckoutStaticKeysPreviousSdkBuilder.php b/lib/Checkout/Previous/CheckoutStaticKeysPreviousSdkBuilder.php index 1dcf2bf..8725ecc 100644 --- a/lib/Checkout/Previous/CheckoutStaticKeysPreviousSdkBuilder.php +++ b/lib/Checkout/Previous/CheckoutStaticKeysPreviousSdkBuilder.php @@ -54,7 +54,8 @@ public function build() $this->getSdkCredentials(), $this->environment, $this->httpClientBuilder, - $this->logger + $this->logger, + $this->enableTelemetry ); if ($this->environmentSubdomain !== null) { $configuration->setEnvironmentSubdomain($this->environmentSubdomain); diff --git a/test/Checkout/Tests/TelemetryTest.php b/test/Checkout/Tests/TelemetryTest.php new file mode 100644 index 0000000..7a67851 --- /dev/null +++ b/test/Checkout/Tests/TelemetryTest.php @@ -0,0 +1,158 @@ +container = []; + + $this->mockHandler = new MockHandler(); + + $handlerStack = HandlerStack::create($this->mockHandler); + + $history = Middleware::history($this->container); + $handlerStack->push($history); + } + + /** + * Counts requests that contain a specific header + */ + private function countRequestsWithHeader($header) + { + $count = 0; + foreach ($this->container as $transaction) { + if ($transaction['request']->hasHeader($header)) { + $count++; + } + } + return $count; + } + + /** + * Creates a CheckoutApi instance with the specified telemetry setting + */ + private function createCheckoutApi($enableTelemetry) + { + $handlerStack = HandlerStack::create($this->mockHandler); + $history = Middleware::history($this->container); + $handlerStack->push($history); + + $client = new Client(['handler' => $handlerStack]); + + // Set up the mock builder to return this client + $httpBuilder = $this->createMock(HttpClientBuilderInterface::class); + $httpBuilder->expects($this->once()) + ->method("getClient") + ->willReturn($client); + $builder = CheckoutSdk::builder() + ->previous() + ->staticKeys() + ->publicKey(parent::$validPreviousPk) + ->secretKey(parent::$validPreviousSk) + ->httpClientBuilder($httpBuilder) + ->environment(Environment::sandbox()); + + if (!$enableTelemetry) { + $builder = $builder->enableTelemetry(false); + } else { + $builder = $builder->enableTelemetry(true); + } + + // Add mock responses to the handler for the number of requests we expect + for ($i = 0; $i < 3; $i++) { + $this->mockHandler->append(new Response(200, [], '{"data": []}')); + } + + return $builder->build(); + } + + /** + * @test + */ + public function shouldSendTelemetryByDefault() + { + $checkoutApi = $this->createCheckoutApi(true); + + for ($i = 0; $i < 3; $i++) { + $checkoutApi->getEventsClient()->retrieveAllEventTypes(); + } + + // Telemetry headers should be present in all requests except the first + $expectedTelemetryHeaderCount = 2; + $telemetryHeaderCount = $this->countRequestsWithHeader('cko-sdk-telemetry'); + + $this->assertEquals( + $expectedTelemetryHeaderCount, + $telemetryHeaderCount, + "Expected exactly {$expectedTelemetryHeaderCount} requests to contain the telemetry header" + ); + } + + /** + * @test + */ + public function shouldNotSendTelemetryWhenOptedOut() + { + $checkoutApi = $this->createCheckoutApi(false); + + for ($i = 0; $i < 3; $i++) { + $checkoutApi->getEventsClient()->retrieveAllEventTypes(); + } + + // No requests should contain telemetry headers + $telemetryHeaderCount = $this->countRequestsWithHeader('cko-sdk-telemetry'); + + $this->assertEquals( + 0, + $telemetryHeaderCount, + 'Expected no requests to contain the telemetry header' + ); + } + + /** + * @test + */ + public function shouldHandleTelemetryQueueAndBottleneck() + { + $checkoutApi = $this->createCheckoutApi(true); + + // Add more mock responses for the additional requests + for ($i = 0; $i < 7; $i++) { // 7 more to make total of 10 + $this->mockHandler->append(new Response(200, [], '{"data": []}')); + } + + $numRequests = 10; + + for ($i = 0; $i < $numRequests; $i++) { + $checkoutApi->getEventsClient()->retrieveAllEventTypes(); + } + + // Since telemetry starts being sent from the second request, + // we expect (numRequests - 1) telemetry headers + $expectedTelemetryHeaderCount = $numRequests - 1; + $telemetryHeaderCount = $this->countRequestsWithHeader('cko-sdk-telemetry'); + + $this->assertEquals( + $expectedTelemetryHeaderCount, + $telemetryHeaderCount, + "Expected {$expectedTelemetryHeaderCount} requests to contain the telemetry header" + ); + } +}