diff --git a/src/main/java/com/checkout/ApacheHttpClientTransport.java b/src/main/java/com/checkout/ApacheHttpClientTransport.java index 7e0db0d6..a6dd328e 100644 --- a/src/main/java/com/checkout/ApacheHttpClientTransport.java +++ b/src/main/java/com/checkout/ApacheHttpClientTransport.java @@ -33,7 +33,6 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.Arrays; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -64,7 +63,7 @@ class ApacheHttpClientTransport implements Transport { private final TransportConfiguration transportConfiguration; private final CheckoutConfiguration configuration; - private static final ThreadLocal> telemetryData = ThreadLocal.withInitial(HashMap::new); + private static final ThreadLocal telemetryData = ThreadLocal.withInitial(RequestMetrics::new); ApacheHttpClientTransport( final URI baseUri, @@ -167,10 +166,7 @@ private Response performCall(final SdkAuthorization authorization, String currentRequestId = UUID.randomUUID().toString(); - if (configuration.isTelemetryEnabled()) { - String telemetryHeader = generateTelemetryHeader(currentRequestId); - request.setHeader("cko-sdk-telemetry", telemetryHeader); - } + addTelemetryHeader(request, currentRequestId); long startTime = System.currentTimeMillis(); @@ -197,35 +193,40 @@ private Response performCall(final SdkAuthorization authorization, } return Response.builder().statusCode(statusCode).headers(headers).build(); } catch (final NoHttpResponseException e) { - log.error("Target server failed to respond with a valid HTTP response."); - return Response.builder().statusCode(HttpStatus.SC_GATEWAY_TIMEOUT).build(); + return handleException(e, "Target server failed to respond with a valid HTTP response."); } catch (final Exception e) { - log.error("Exception occurred during the execution of the client...", e); + return handleException(e, "Exception occurred during the execution of the client..."); } - return Response.builder().statusCode(transportConfiguration.getDefaultHttpStatusCode()).build(); } - private String generateTelemetryHeader(String currentRequestId) { - Map data = getTelemetryData(); - String prevRequestId = (String) data.get("prevRequestId"); - Long prevRequestDuration = (Long) data.get("prevRequestDuration"); + private void addTelemetryHeader(HttpUriRequest request, String currentRequestId) { + if (configuration.isTelemetryEnabled()) { + String telemetryHeader = generateTelemetryHeader(currentRequestId); + request.setHeader("cko-sdk-telemetry", telemetryHeader); + } + } - return String.format("{\"requestId\":\"%s\",\"prevRequestId\":\"%s\",\"prevRequestDuration\":%d}", - currentRequestId, - prevRequestId != null ? prevRequestId : "N/A", - prevRequestDuration != null ? prevRequestDuration : 0); + private String generateTelemetryHeader(String currentRequestId) { + RequestMetrics metrics = getTelemetryData(); + metrics.setRequestId(currentRequestId); + return metrics.toTelemetryHeader(); } private static void updateTelemetryData(String requestId, long duration) { - Map data = telemetryData.get(); - data.put("prevRequestId", requestId); - data.put("prevRequestDuration", duration); + RequestMetrics metrics = telemetryData.get(); + metrics.setPrevRequestId(requestId); + metrics.setPrevRequestDuration(duration); } - private static Map getTelemetryData() { + private static RequestMetrics getTelemetryData() { return telemetryData.get(); } + private Response handleException(Exception e, String errorMessage) { + log.error(errorMessage, e); + return Response.builder().statusCode(transportConfiguration.getDefaultHttpStatusCode()).build(); + } + private Header[] sanitiseHeaders(final Header[] headers) { return Arrays.stream(headers) .filter(it -> !it.getName().equals(AUTHORIZATION)) @@ -255,4 +256,4 @@ private String getRequestUrl(final String path) { throw new CheckoutException(e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/checkout/RequestMetrics.java b/src/main/java/com/checkout/RequestMetrics.java index 16a6cabe..1f4a9a58 100644 --- a/src/main/java/com/checkout/RequestMetrics.java +++ b/src/main/java/com/checkout/RequestMetrics.java @@ -1,17 +1,26 @@ package com.checkout; +import com.google.gson.annotations.SerializedName; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor public class RequestMetrics { + + @SerializedName("prev_request_id") + private String prevRequestId; + + @SerializedName("request_id") private String requestId; + + @SerializedName("prev_request_duration") private Long prevRequestDuration; - private String prevRequestId; - public RequestMetrics(Long prevRequestDuration, String prevRequestId) { - this.prevRequestDuration = prevRequestDuration; - this.prevRequestId = prevRequestId; + public String toTelemetryHeader() { + return String.format("{\"prev_request_id\":\"%s\",\"request_id\":\"%s\",\"prev_request_duration\":%d}", + prevRequestId != null ? prevRequestId : "N/A", + requestId != null ? requestId : "N/A", + prevRequestDuration != null ? prevRequestDuration : 0); } -} \ No newline at end of file +} diff --git a/src/test/java/com/checkout/CheckoutSdkTelemetryIntegrationTest.java b/src/test/java/com/checkout/CheckoutSdkTelemetryIntegrationTest.java index 54d669f5..76af3495 100644 --- a/src/test/java/com/checkout/CheckoutSdkTelemetryIntegrationTest.java +++ b/src/test/java/com/checkout/CheckoutSdkTelemetryIntegrationTest.java @@ -1,5 +1,7 @@ package com.checkout; +import com.google.gson.Gson; +import lombok.val; import org.apache.http.Header; import org.apache.http.ProtocolVersion; import org.apache.http.client.methods.CloseableHttpResponse; @@ -9,12 +11,14 @@ import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicStatusLine; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -26,282 +30,185 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.mockito.internal.verification.VerificationModeFactory.times; +import static org.mockito.Mockito.*; class CheckoutSdkTelemetryIntegrationTest { private static final Logger log = LoggerFactory.getLogger(CheckoutSdkTelemetryIntegrationTest.class); - @Test - void shouldSendTelemetryByDefault() throws Exception { - // Mock CloseableHttpClient and response + private CloseableHttpClient setupHttpClientMock() throws Exception { CloseableHttpClient httpClientMock = mock(CloseableHttpClient.class); CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); - // Mock the response status line as 200 OK when(responseMock.getStatusLine()).thenReturn( new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK") ); - - // Mock response headers when(responseMock.getAllHeaders()).thenReturn(new Header[]{ new BasicHeader("Content-Type", "application/json"), new BasicHeader("Cko-Request-Id", "test-request-id") }); - // Mock response entity (simulated JSON data) - StringEntity entity = new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}"); - when(responseMock.getEntity()).thenReturn(entity); - - // Configure the HTTP client mock to return the mock response + when(responseMock.getEntity()).thenReturn(new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}")); when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(responseMock); - // Mock HttpClientBuilder to return the mocked HttpClient + return httpClientMock; + } + + private CheckoutApi buildCheckoutApi(CloseableHttpClient httpClientMock, boolean telemetryEnabled) { HttpClientBuilder httpClientBuilderMock = mock(HttpClientBuilder.class); when(httpClientBuilderMock.setRedirectStrategy(any())).thenReturn(httpClientBuilderMock); when(httpClientBuilderMock.build()).thenReturn(httpClientMock); - // Build CheckoutApi with mocked components - CheckoutApi checkoutApi = CheckoutSdk.builder() + return CheckoutSdk.builder() .staticKeys() .publicKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_PUBLIC_KEY"))) .secretKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_SECRET_KEY"))) + .recordTelemetry(telemetryEnabled) .environment(SANDBOX) .httpClientBuilder(httpClientBuilderMock) .build(); + } - // Execute some requests to test telemetry - checkoutApi.workflowsClient().getWorkflows().get(); - checkoutApi.workflowsClient().getWorkflows().get(); - checkoutApi.workflowsClient().getWorkflows().get(); - - // Capture and verify requests - ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); - verify(httpClientMock, atLeastOnce()).execute(requestCaptor.capture()); - - // Assert that the telemetry header is present by default + private void assertTelemetryHeaderPresent(ArgumentCaptor requestCaptor) { boolean telemetryHeaderFound = requestCaptor.getAllValues().stream() .anyMatch(req -> req.containsHeader("cko-sdk-telemetry")); - assertTrue(telemetryHeaderFound, "The telemetry header should be present by default"); } - @Test - void shouldNotSendTelemetryWhenOptedOut() throws Exception { - // Mock CloseableHttpClient and response - CloseableHttpClient httpClientMock = mock(CloseableHttpClient.class); - CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); + private void assertTelemetryHeaderContent(String telemetryHeader) { + Gson gson = new Gson(); + val telemetryData = gson.fromJson(telemetryHeader, Map.class); - // Mock the response status line as 200 OK - when(responseMock.getStatusLine()).thenReturn( - new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK") - ); + assertTrue(telemetryData.containsKey("request_id"), "Telemetry header must contain 'request_id'"); + assertTrue(telemetryData.containsKey("prev_request_id"), "Telemetry header must contain 'prev_request_id'"); + assertTrue(telemetryData.containsKey("prev_request_duration"), "Telemetry header must contain 'prev_request_duration'"); + assertEquals(3, telemetryData.size(), "Telemetry header must only contain 'request_id', 'prev_request_id', and 'prev_request_duration'"); + } - // Mock response headers - when(responseMock.getAllHeaders()).thenReturn(new Header[]{ - new BasicHeader("Content-Type", "application/json"), - new BasicHeader("Cko-Request-Id", "test-request-id") - }); + @Test + void shouldSendTelemetryByDefault() throws Exception { + CloseableHttpClient httpClientMock = setupHttpClientMock(); + CheckoutApi checkoutApi = buildCheckoutApi(httpClientMock, true); - // Mock response entity (simulated JSON data) - StringEntity entity = new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}"); - when(responseMock.getEntity()).thenReturn(entity); + checkoutApi.workflowsClient().getWorkflows().get(); + checkoutApi.workflowsClient().getWorkflows().get(); + checkoutApi.workflowsClient().getWorkflows().get(); - // Configure the HTTP client mock to return the mock response - when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(responseMock); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); + verify(httpClientMock, atLeastOnce()).execute(requestCaptor.capture()); - // Mock HttpClientBuilder to return the mocked HttpClient - HttpClientBuilder httpClientBuilderMock = mock(HttpClientBuilder.class); - when(httpClientBuilderMock.setRedirectStrategy(any())).thenReturn(httpClientBuilderMock); - when(httpClientBuilderMock.build()).thenReturn(httpClientMock); + assertTelemetryHeaderPresent(requestCaptor); + } - // Build CheckoutApi with mocked components and telemetry disabled - CheckoutApi checkoutApi = CheckoutSdk.builder() - .staticKeys() - .publicKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_PUBLIC_KEY"))) - .secretKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_SECRET_KEY"))) - .recordTelemetry(false) // Disable telemetry - .environment(SANDBOX) - .httpClientBuilder(httpClientBuilderMock) - .build(); + @Test + void shouldNotSendTelemetryWhenOptedOut() throws Exception { + CloseableHttpClient httpClientMock = setupHttpClientMock(); + CheckoutApi checkoutApi = buildCheckoutApi(httpClientMock, false); - // Execute some requests to test telemetry checkoutApi.workflowsClient().getWorkflows().get(); checkoutApi.workflowsClient().getWorkflows().get(); - // Capture and verify requests ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); verify(httpClientMock, atLeastOnce()).execute(requestCaptor.capture()); - // Assert that the telemetry header is not present boolean telemetryHeaderFound = requestCaptor.getAllValues().stream() .anyMatch(req -> req.containsHeader("cko-sdk-telemetry")); - assertFalse(telemetryHeaderFound, "The telemetry header should not be present when telemetry is disabled"); } @Test - void shouldHandleConcurrentRequests() throws Exception { - // Mock CloseableHttpClient and response - CloseableHttpClient httpClientMock = mock(CloseableHttpClient.class); - CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); + void shouldValidateTelemetryHeaderContent() throws Exception { + CloseableHttpClient httpClientMock = setupHttpClientMock(); + CheckoutApi checkoutApi = buildCheckoutApi(httpClientMock, true); - // Mock the response status line as 200 OK - when(responseMock.getStatusLine()).thenReturn( - new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK") - ); - - // Mock response headers - when(responseMock.getAllHeaders()).thenReturn(new Header[]{ - new BasicHeader("Content-Type", "application/json"), - new BasicHeader("Cko-Request-Id", "test-request-id") - }); - - // Mock response entity - StringEntity entity = new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}"); - when(responseMock.getEntity()).thenReturn(entity); + checkoutApi.workflowsClient().getWorkflows().get(); - // Configure the HTTP client mock to return the mock response - when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(responseMock); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); + verify(httpClientMock, times(1)).execute(requestCaptor.capture()); - // Mock HttpClientBuilder to return the mocked HttpClient - HttpClientBuilder httpClientBuilderMock = mock(HttpClientBuilder.class); - when(httpClientBuilderMock.setRedirectStrategy(any())).thenReturn(httpClientBuilderMock); - when(httpClientBuilderMock.build()).thenReturn(httpClientMock); + String telemetryHeader = requestCaptor.getValue().getFirstHeader("cko-sdk-telemetry").getValue(); + assertTelemetryHeaderContent(telemetryHeader); + } - // Build CheckoutApi with mocked components - CheckoutApi checkoutApi = CheckoutSdk.builder() - .staticKeys() - .publicKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_PUBLIC_KEY"))) - .secretKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_SECRET_KEY"))) - .environment(SANDBOX) - .httpClientBuilder(httpClientBuilderMock) - .build(); + @Test + void shouldHandleConcurrentRequests() throws Exception { + CloseableHttpClient httpClientMock = setupHttpClientMock(); + CheckoutApi checkoutApi = buildCheckoutApi(httpClientMock, true); - // Prepare a concurrent test environment int threadCount = 10; CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch doneLatch = new CountDownLatch(threadCount); ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - // Submit concurrent tasks for (int i = 0; i < threadCount; i++) { executorService.submit(() -> { try { - startLatch.await(); // Wait for all threads to be ready + startLatch.await(); checkoutApi.workflowsClient().getWorkflows().get(); } catch (Exception e) { - log.error("Error occurred during concurrent request: {}", e.getMessage(), e); + log.error("Error during concurrent request", e); } finally { - doneLatch.countDown(); // Signal that the thread has completed + doneLatch.countDown(); } }); } - // Start all threads simultaneously startLatch.countDown(); - doneLatch.await(); // Wait for all threads to finish + doneLatch.await(); executorService.shutdown(); - // Capture and verify requests ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); verify(httpClientMock, times(threadCount)).execute(requestCaptor.capture()); - List requests = requestCaptor.getAllValues(); - assertFalse(requests.isEmpty(), "requests mustn't be empty."); - - // Ensure telemetry header exists and is unique for all concurrent requests - List telemetryHeaders = requests.stream() + List telemetryHeaders = requestCaptor.getAllValues().stream() .map(req -> req.getFirstHeader("cko-sdk-telemetry").getValue()) .collect(Collectors.toList()); - assertEquals( - telemetryHeaders.stream().distinct().count(), - threadCount, - "All concurrent requests should have unique telemetry headers" - ); + assertEquals(threadCount, telemetryHeaders.size(), "All requests must include telemetry headers"); + assertEquals(threadCount, telemetryHeaders.stream().distinct().count(), "All requests should have unique telemetry headers"); } @Test + @Disabled("run as needed") void shouldHandleHighLoadRequestsConcurrently() throws Exception { - // Mock CloseableHttpClient and response - CloseableHttpClient httpClientMock = mock(CloseableHttpClient.class); - CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); - - // Mock the response status line as 200 OK - when(responseMock.getStatusLine()).thenReturn( - new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK") - ); - - // Mock response headers - when(responseMock.getAllHeaders()).thenReturn(new Header[]{ - new BasicHeader("Content-Type", "application/json"), - new BasicHeader("Cko-Request-Id", "test-request-id") - }); - - // Mock response entity - StringEntity entity = new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}"); - when(responseMock.getEntity()).thenReturn(entity); - - // Configure the HTTP client mock to return the mock response - when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(responseMock); + CloseableHttpClient httpClientMock = setupHttpClientMock(); + CheckoutApi checkoutApi = buildCheckoutApi(httpClientMock, true); - // Mock HttpClientBuilder to return the mocked HttpClient - HttpClientBuilder httpClientBuilderMock = mock(HttpClientBuilder.class); - when(httpClientBuilderMock.setRedirectStrategy(any())).thenReturn(httpClientBuilderMock); - when(httpClientBuilderMock.build()).thenReturn(httpClientMock); - - // Build CheckoutApi with mocked components - CheckoutApi checkoutApi = CheckoutSdk.builder() - .staticKeys() - .publicKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_PUBLIC_KEY"))) - .secretKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_SECRET_KEY"))) - .environment(SANDBOX) - .httpClientBuilder(httpClientBuilderMock) - .build(); - - // Simulate high load - int requestCount = 1000; // Total number of requests + int requestCount = 1000; + int threadCount = 50; CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch doneLatch = new CountDownLatch(requestCount); - ExecutorService executorService = Executors.newFixedThreadPool(50); // 50 threads + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); for (int i = 0; i < requestCount; i++) { executorService.submit(() -> { try { - startLatch.await(); // Wait for all threads to be ready + startLatch.await(); checkoutApi.workflowsClient().getWorkflows().get(); } catch (Exception e) { - log.error("Error occurred during concurrent request: {}", e.getMessage(), e); + log.error("Error during high-load request", e); } finally { - doneLatch.countDown(); // Signal completion + doneLatch.countDown(); } }); } - // Start all threads and wait for them to finish startLatch.countDown(); - doneLatch.await(); // Wait for all threads to complete + doneLatch.await(); executorService.shutdown(); - // Capture and verify requests ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); verify(httpClientMock, times(requestCount)).execute(requestCaptor.capture()); List requests = requestCaptor.getAllValues(); - assertFalse(requests.isEmpty(), "Requests mustn't be empty."); + assertFalse(requests.isEmpty(), "Requests must not be empty"); - // Ensure all telemetry headers are present and correctly formed List telemetryHeaders = requests.stream() .map(req -> req.getFirstHeader("cko-sdk-telemetry").getValue()) .collect(Collectors.toList()); - assertEquals(requestCount, telemetryHeaders.size(), "All requests must include telemetry headers."); - assertTrue(telemetryHeaders.stream().allMatch(header -> header.contains("\"requestId\"")), - "Each telemetry header must include a 'requestId'."); + assertEquals(requestCount, telemetryHeaders.size(), "All requests must include telemetry headers"); + assertEquals(requestCount, telemetryHeaders.stream().distinct().count(), + "Each request should have a unique telemetry header"); } -} \ No newline at end of file +}