diff --git a/README.md b/README.md index 4e27a7c8..cbd5bf30 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,29 @@ The execution of integration tests require the following environment variables s * For Default account systems (OAuth): `CHECKOUT_DEFAULT_OAUTH_CLIENT_ID` & `CHECKOUT_DEFAULT_OAUTH_CLIENT_SECRET` * For Previous account systems (ABC): `CHECKOUT_PREVIOUS_PUBLIC_KEY` & `CHECKOUT_PREVIOUS_SECRET_KEY` +## Telemetry +Request telemetry is enabled by default in the .NET SDK. Request latency is included in the telemetry data. Recording the request latency allows Checkout.com to continuously monitor and improve the merchant experience. + +Request telemetry can be disabled by opting out during CheckoutSdk builder step: +``` +ICheckoutApi api = CheckoutSdk.Builder().StaticKeys() + .SecretKey("secret_key") + .RecordTelemetry(false) + .Environment(Environment.Sandbox) + .Build(); +``` + +Or when using `CheckoutSDK.Extensions.Microsoft`: +``` +{ + "Checkout": { + ... + "RecordTelemetry": false, + ... + } +} +``` + ## Code of Conduct Please refer to [Code of Conduct](CODE_OF_CONDUCT.md) diff --git a/src/CheckoutSdk.Extensions/CheckoutOptions.cs b/src/CheckoutSdk.Extensions/CheckoutOptions.cs index e774e377..19e00565 100644 --- a/src/CheckoutSdk.Extensions/CheckoutOptions.cs +++ b/src/CheckoutSdk.Extensions/CheckoutOptions.cs @@ -23,6 +23,7 @@ public class CheckoutOptions public PlatformType? PlatformType { get; set; } public IHttpClientFactory HttpClientFactory { get; set; } - + + public bool RecordTelemetry { get; set; } = true; } } \ No newline at end of file diff --git a/src/CheckoutSdk.Extensions/CheckoutServiceCollection.cs b/src/CheckoutSdk.Extensions/CheckoutServiceCollection.cs index 16ebc4c7..7294449c 100644 --- a/src/CheckoutSdk.Extensions/CheckoutServiceCollection.cs +++ b/src/CheckoutSdk.Extensions/CheckoutServiceCollection.cs @@ -135,6 +135,7 @@ private static void SetCommonAttributes( { builder.HttpClientFactory(httpClientFactory); } + builder.RecordTelemetry(options.RecordTelemetry); } } } \ No newline at end of file diff --git a/src/CheckoutSdk/AbstractCheckoutSdkBuilder.cs b/src/CheckoutSdk/AbstractCheckoutSdkBuilder.cs index fe06134c..66505e62 100644 --- a/src/CheckoutSdk/AbstractCheckoutSdkBuilder.cs +++ b/src/CheckoutSdk/AbstractCheckoutSdkBuilder.cs @@ -9,6 +9,8 @@ namespace Checkout public abstract class AbstractCheckoutSdkBuilder { protected Environment Env = Checkout.Environment.Sandbox; + + private bool _recordTelemetry = true; private EnvironmentSubdomain _envSubdomain; protected IHttpClientFactory ClientFactory = new DefaultHttpClientFactory(); @@ -24,6 +26,12 @@ public AbstractCheckoutSdkBuilder EnvironmentSubdomain(string subdomain) return this; } + public AbstractCheckoutSdkBuilder RecordTelemetry(bool recordTelemetry) + { + _recordTelemetry = recordTelemetry; + return this; + } + #if (NETSTANDARD2_0_OR_GREATER || NETCOREAPP3_1_OR_GREATER) public AbstractCheckoutSdkBuilder LogProvider(ILoggerFactory loggerFactory) { @@ -40,7 +48,7 @@ public AbstractCheckoutSdkBuilder HttpClientFactory(IHttpClientFactory httpCl protected CheckoutConfiguration GetCheckoutConfiguration() { - return new CheckoutConfiguration(GetSdkCredentials(), Env, _envSubdomain, ClientFactory); + return new CheckoutConfiguration(GetSdkCredentials(), Env, _envSubdomain, ClientFactory, _recordTelemetry); } protected abstract SdkCredentials GetSdkCredentials(); diff --git a/src/CheckoutSdk/ApiClient.cs b/src/CheckoutSdk/ApiClient.cs index eb85e005..5239d818 100644 --- a/src/CheckoutSdk/ApiClient.cs +++ b/src/CheckoutSdk/ApiClient.cs @@ -3,7 +3,9 @@ using System.Web; #endif using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; @@ -19,16 +21,21 @@ public class ApiClient : IApiClient private readonly ILogger _log = LogProvider.GetLogger(typeof(ApiClient)); #endif + private const string sdkTelemetryHeader = "cko-sdk-telemetry"; + private const int maxCountInTelemetryQueue = 10; private readonly HttpClient _httpClient; private readonly Uri _baseUri; private readonly ISerializer _serializer = new JsonSerializer(); + private readonly ConcurrentQueue requestMetricsQueue = new ConcurrentQueue(); + private readonly bool _enableTelemetry; - public ApiClient(IHttpClientFactory httpClientFactory, Uri baseUri) + public ApiClient(IHttpClientFactory httpClientFactory, Uri baseUri, bool enableTelemetry) { CheckoutUtils.ValidateParams("httpClientFactory", httpClientFactory, "baseUri", baseUri); var httpClient = httpClientFactory.CreateClient(); _baseUri = baseUri; _httpClient = httpClient; + _enableTelemetry = enableTelemetry; } public async Task Get( @@ -170,7 +177,7 @@ public async Task Query( $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}")); #endif - path = $"{path}?{queryString}"; + path = $"{path}?{queryString}"; } } @@ -246,8 +253,37 @@ private async Task Invoke( { httpRequest.Headers.Add("Cko-Idempotency-Key", idempotencyKey); } + + if (_enableTelemetry) + { + var currentRequestId = Guid.NewGuid().ToString(); + if (requestMetricsQueue.TryDequeue(out var lastRequestMetric)) + { + lastRequestMetric.RequestId = currentRequestId; + httpRequest.Headers.TryAddWithoutValidation(sdkTelemetryHeader, _serializer.Serialize(lastRequestMetric)); + } - return await _httpClient.SendAsync(httpRequest, cancellationToken); + Stopwatch stopwatch = new Stopwatch(); + stopwatch.Start(); + var result = await _httpClient.SendAsync(httpRequest, cancellationToken); + stopwatch.Stop(); + + if (requestMetricsQueue.Count < maxCountInTelemetryQueue) + { + lastRequestMetric.PrevRequestDuration = stopwatch.ElapsedMilliseconds; + requestMetricsQueue.Enqueue(new RequestMetrics() + { + PrevRequestDuration = stopwatch.ElapsedMilliseconds, + PrevRequestId = currentRequestId + }); + } + + return result; + } + else + { + return await _httpClient.SendAsync(httpRequest, cancellationToken); + } } private async Task ValidateResponseAsync(HttpResponseMessage httpResponse) diff --git a/src/CheckoutSdk/CheckoutApi.cs b/src/CheckoutSdk/CheckoutApi.cs index 2fe11e1a..4b81a689 100644 --- a/src/CheckoutSdk/CheckoutApi.cs +++ b/src/CheckoutSdk/CheckoutApi.cs @@ -79,25 +79,29 @@ private static ApiClient BaseApiClient(CheckoutConfiguration configuration) return new ApiClient(configuration.HttpClientFactory, configuration.EnvironmentSubdomain != null ? configuration.EnvironmentSubdomain.ApiUri - : configuration.Environment.GetAttribute().ApiUri); + : configuration.Environment.GetAttribute().ApiUri, + configuration.RecordTelemetry); } private static ApiClient FilesApiClient(CheckoutConfiguration configuration) { return new ApiClient(configuration.HttpClientFactory, - configuration.Environment.GetAttribute().FilesApiUri); + configuration.Environment.GetAttribute().FilesApiUri, + configuration.RecordTelemetry); } private static ApiClient TransfersApiClient(CheckoutConfiguration configuration) { return new ApiClient(configuration.HttpClientFactory, - configuration.Environment.GetAttribute().TransfersApiUri); + configuration.Environment.GetAttribute().TransfersApiUri, + configuration.RecordTelemetry); } private static ApiClient BalancesApiClient(CheckoutConfiguration configuration) { return new ApiClient(configuration.HttpClientFactory, - configuration.Environment.GetAttribute().BalancesApiUri); + configuration.Environment.GetAttribute().BalancesApiUri, + configuration.RecordTelemetry); } diff --git a/src/CheckoutSdk/CheckoutConfiguration.cs b/src/CheckoutSdk/CheckoutConfiguration.cs index 29a02299..04812eab 100644 --- a/src/CheckoutSdk/CheckoutConfiguration.cs +++ b/src/CheckoutSdk/CheckoutConfiguration.cs @@ -10,6 +10,8 @@ public class CheckoutConfiguration public IHttpClientFactory HttpClientFactory { get; } + public bool RecordTelemetry { get; } + public CheckoutConfiguration( SdkCredentials sdkCredentials, Environment environment, @@ -28,7 +30,8 @@ public CheckoutConfiguration( SdkCredentials sdkCredentials, Environment environment, EnvironmentSubdomain environmentSubdomain, - IHttpClientFactory httpClientFactory) + IHttpClientFactory httpClientFactory, + bool recordTelemetry) { CheckoutUtils.ValidateParams( "sdkCredentials", sdkCredentials, @@ -38,6 +41,7 @@ public CheckoutConfiguration( Environment = environment; EnvironmentSubdomain = environmentSubdomain; HttpClientFactory = httpClientFactory; + RecordTelemetry = recordTelemetry; } } } \ No newline at end of file diff --git a/src/CheckoutSdk/Previous/AbstractCheckoutApmApi.cs b/src/CheckoutSdk/Previous/AbstractCheckoutApmApi.cs index 83947086..554eac2c 100644 --- a/src/CheckoutSdk/Previous/AbstractCheckoutApmApi.cs +++ b/src/CheckoutSdk/Previous/AbstractCheckoutApmApi.cs @@ -15,7 +15,8 @@ protected AbstractCheckoutApmApi(CheckoutConfiguration configuration) var apiClient = new ApiClient(configuration.HttpClientFactory, configuration.EnvironmentSubdomain != null ? configuration.EnvironmentSubdomain.ApiUri - : configuration.Environment.GetAttribute().ApiUri); + : configuration.Environment.GetAttribute().ApiUri, + configuration.RecordTelemetry); _idealClient = new IdealClient(apiClient, configuration); _klarnaClient = new KlarnaClient(apiClient, configuration); _sepaClient = new SepaClient(apiClient, configuration); diff --git a/src/CheckoutSdk/Previous/CheckoutApi.cs b/src/CheckoutSdk/Previous/CheckoutApi.cs index 34938855..d801e43e 100644 --- a/src/CheckoutSdk/Previous/CheckoutApi.cs +++ b/src/CheckoutSdk/Previous/CheckoutApi.cs @@ -33,7 +33,8 @@ public CheckoutApi(CheckoutConfiguration configuration) : base(configuration) var apiClient = new ApiClient(configuration.HttpClientFactory, configuration.EnvironmentSubdomain != null ? configuration.EnvironmentSubdomain.ApiUri - : configuration.Environment.GetAttribute().ApiUri); + : configuration.Environment.GetAttribute().ApiUri, + configuration.RecordTelemetry); _tokensClient = new TokensClient(apiClient, configuration); _customersClient = new CustomersClient(apiClient, configuration); _sourcesClient = new SourcesClient(apiClient, configuration); diff --git a/src/CheckoutSdk/RequestMetrics.cs b/src/CheckoutSdk/RequestMetrics.cs new file mode 100644 index 00000000..15279bee --- /dev/null +++ b/src/CheckoutSdk/RequestMetrics.cs @@ -0,0 +1,9 @@ +namespace Checkout +{ + public struct RequestMetrics + { + public string PrevRequestId { get; set; } + public string RequestId { get; set; } + public long PrevRequestDuration { get; set; } + } +} \ No newline at end of file diff --git a/test/CheckoutSdkTest/CheckoutApiTest.cs b/test/CheckoutSdkTest/CheckoutApiTest.cs index 91dcd0ae..44a939e2 100644 --- a/test/CheckoutSdkTest/CheckoutApiTest.cs +++ b/test/CheckoutSdkTest/CheckoutApiTest.cs @@ -16,7 +16,7 @@ public void ShouldInstantiateAndRetrieveClientsPrevious() httpClientFactoryMock.Setup(mock => mock.CreateClient()) .Returns(new HttpClient()); var checkoutConfiguration = new CheckoutConfiguration(sdkCredentialsMock.Object, Environment.Sandbox, null, - httpClientFactoryMock.Object); + httpClientFactoryMock.Object, false); //Act Previous.ICheckoutApi checkoutApi = new Previous.CheckoutApi(checkoutConfiguration); @@ -48,7 +48,7 @@ public void ShouldInstantiateAndRetrieveClientsDefault() httpClientFactoryMock.Setup(mock => mock.CreateClient()) .Returns(new HttpClient()); var checkoutConfiguration = new CheckoutConfiguration(sdkCredentialsMock.Object, Environment.Sandbox, null, - httpClientFactoryMock.Object); + httpClientFactoryMock.Object, false); //Act ICheckoutApi checkoutApi = new CheckoutApi(checkoutConfiguration); diff --git a/test/CheckoutSdkTest/CheckoutConfigurationTest.cs b/test/CheckoutSdkTest/CheckoutConfigurationTest.cs index 498cd79a..0c71f919 100644 --- a/test/CheckoutSdkTest/CheckoutConfigurationTest.cs +++ b/test/CheckoutSdkTest/CheckoutConfigurationTest.cs @@ -13,7 +13,7 @@ private void ShouldCreateConfiguration() var credentials = new StaticKeysSdkCredentials(ValidDefaultSk, ValidDefaultPk); var httpClientFactoryMock = new Mock(); var configuration = - new CheckoutConfiguration(credentials, Environment.Production, null, httpClientFactoryMock.Object); + new CheckoutConfiguration(credentials, Environment.Production, null, httpClientFactoryMock.Object, false); configuration.Environment.ShouldBe(Environment.Production); configuration.SdkCredentials.ShouldBeAssignableTo(typeof(StaticKeysSdkCredentials)); } @@ -30,7 +30,7 @@ public void ShouldCreateConfigurationWithSubdomain(string subdomain, string expe var httpClientFactoryMock = new Mock(); var environmentSubdomain = new EnvironmentSubdomain(Environment.Sandbox, subdomain); var configuration = new CheckoutConfiguration(credentials, Environment.Sandbox, environmentSubdomain, - httpClientFactoryMock.Object); + httpClientFactoryMock.Object, false); configuration.Environment.ShouldBe(Environment.Sandbox); configuration.EnvironmentSubdomain.ApiUri.ToString().ShouldBe(expectedUri); @@ -50,7 +50,7 @@ public void ShouldCreateConfigurationWithBadSubdomain(string subdomain, string e var httpClientFactoryMock = new Mock(); var environmentSubdomain = new EnvironmentSubdomain(Environment.Sandbox, subdomain); var configuration = new CheckoutConfiguration(credentials, Environment.Sandbox, environmentSubdomain, - httpClientFactoryMock.Object); + httpClientFactoryMock.Object, false); configuration.Environment.ShouldBe(Environment.Sandbox); configuration.EnvironmentSubdomain.ApiUri.ToString().ShouldBe(expectedUri); diff --git a/test/CheckoutSdkTest/CheckoutSdkTelemetryIntegrationTest.cs b/test/CheckoutSdkTest/CheckoutSdkTelemetryIntegrationTest.cs new file mode 100644 index 00000000..f0ffa26d --- /dev/null +++ b/test/CheckoutSdkTest/CheckoutSdkTelemetryIntegrationTest.cs @@ -0,0 +1,126 @@ +using Shouldly; +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Moq; +using Moq.Protected; + +namespace Checkout +{ + public class CheckoutSdkTelemetryIntegrationTest + { + [Fact] + public async Task ShouldSendTelemetryByDefault() + { + Mock mockedMessageHandler = new Mock(); + mockedMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((HttpRequestMessage request, CancellationToken token) => + { + var jsonResponse = "[]"; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, System.Text.Encoding.UTF8, "application/json") + }; + return Task.FromResult(response); + }) + .Verifiable(); + + var httpFactory = new TestingClientFactory(mockedMessageHandler.Object); + + var checkoutApi = CheckoutSdk + .Builder() + .Previous() + .StaticKeys() + .PublicKey(System.Environment.GetEnvironmentVariable("CHECKOUT_PREVIOUS_PUBLIC_KEY")) + .SecretKey(System.Environment.GetEnvironmentVariable("CHECKOUT_PREVIOUS_SECRET_KEY")) + .Environment(Environment.Sandbox) + .HttpClientFactory(httpFactory) + .Build(); + + checkoutApi.ShouldNotBeNull(); + + await checkoutApi.EventsClient().RetrieveAllEventTypes(); + await checkoutApi.EventsClient().RetrieveAllEventTypes(); + await checkoutApi.EventsClient().RetrieveAllEventTypes(); + + mockedMessageHandler.Protected().Verify( + "SendAsync", + Times.Exactly(2), // we expected two sends to contain the telemetry header + ItExpr.Is(req => + req.Headers.Contains("cko-sdk-telemetry") + ), + ItExpr.IsAny() + ); + } + + [Fact] + public async Task ShouldNotSendTelemetryWhenOptedOut() + { + Mock mockedMessageHandler = new Mock(); + mockedMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((HttpRequestMessage request, CancellationToken token) => + { + var jsonResponse = "[]"; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, System.Text.Encoding.UTF8, "application/json") + }; + return Task.FromResult(response); + }) + .Verifiable(); + + var httpFactory = new TestingClientFactory(mockedMessageHandler.Object); + + var checkoutApi = CheckoutSdk + .Builder() + .Previous() + .StaticKeys() + .PublicKey(System.Environment.GetEnvironmentVariable("CHECKOUT_PREVIOUS_PUBLIC_KEY")) + .SecretKey(System.Environment.GetEnvironmentVariable("CHECKOUT_PREVIOUS_SECRET_KEY")) + .RecordTelemetry(false) + .Environment(Environment.Sandbox) + .HttpClientFactory(httpFactory) + .Build(); + + checkoutApi.ShouldNotBeNull(); + + await checkoutApi.EventsClient().RetrieveAllEventTypes(); + await checkoutApi.EventsClient().RetrieveAllEventTypes(); + + mockedMessageHandler.Protected().Verify( + "SendAsync", + Times.Exactly(0), // we expected only one to contain the telemetry header + ItExpr.Is(req => + req.Headers.Contains("cko-sdk-telemetry") + ), + ItExpr.IsAny() + ); + } + + private class TestingClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + + public TestingClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient() + { + var httpClient = new HttpClient(_handler); + httpClient.Timeout = TimeSpan.FromSeconds(2); + return httpClient; + } + } + } +} \ No newline at end of file