Skip to content

Commit

Permalink
Merge pull request #1513 from stripe/ob-telemetry
Browse files Browse the repository at this point in the history
Add support for telemetry
  • Loading branch information
ob-stripe authored Feb 13, 2019
2 parents 82dfb26 + b688732 commit 29a4624
Show file tree
Hide file tree
Showing 5 changed files with 320 additions and 0 deletions.
91 changes: 91 additions & 0 deletions src/Stripe.net/Infrastructure/Public/RequestTelemetry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
namespace Stripe
{
using System.Collections.Concurrent;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using Newtonsoft.Json;

/// <summary>
/// Helper class used by <see cref="SystemNetHttpClient"/> to manage request telemetry.
/// </summary>
public class RequestTelemetry
{
private readonly ConcurrentQueue<RequestMetrics> prevRequestMetrics
= new ConcurrentQueue<RequestMetrics>();

private static long MaxRequestMetricsQueueSize => 100;

/// <summary>
/// If telemetry is enabled and there is at least one metrics item in the queue, then add
/// a <c>X-Stripe-Client-Telemetry</c> header with the item; otherwise, do nothing.
/// </summary>
/// <param name="headers">The request headers.</param>
public void MaybeAddTelemetryHeader(HttpHeaders headers)
{
if (!StripeConfiguration.EnableTelemetry)
{
return;
}

RequestMetrics requestMetrics;
if (!this.prevRequestMetrics.TryDequeue(out requestMetrics))
{
return;
}

var payload = new ClientTelemetryPayload { LastRequestMetrics = requestMetrics };

headers.Add("X-Stripe-Client-Telemetry", JsonConvert.SerializeObject(payload));
}

/// <summary>
/// If telemetry is enabled and the queue is not full, then enqueue a new metrics item;
/// otherwise, do nothing.
/// </summary>
/// <param name="response">The HTTP response message.</param>
/// <param name="durationMs">The request duration, in milliseconds.</param>
public void MaybeEnqueueMetrics(HttpResponseMessage response, long durationMs)
{
if (!StripeConfiguration.EnableTelemetry)
{
return;
}

if (!response.Headers.Contains("Request-Id"))
{
return;
}

if (this.prevRequestMetrics.Count >= MaxRequestMetricsQueueSize)
{
return;
}

var requestId = response.Headers.GetValues("Request-Id").First();

var metrics = new RequestMetrics
{
RequestId = requestId,
RequestDurationMs = durationMs,
};

this.prevRequestMetrics.Enqueue(metrics);
}

private class ClientTelemetryPayload
{
[JsonProperty("last_request_metrics")]
public RequestMetrics LastRequestMetrics { get; set; }
}

private class RequestMetrics
{
[JsonProperty("request_id")]
public string RequestId { get; set; }

[JsonProperty("request_duration_ms")]
public long RequestDurationMs { get; set; }
}
}
}
3 changes: 3 additions & 0 deletions src/Stripe.net/Infrastructure/Public/StripeConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ public static string ApiKey
/// <summary>Gets or sets the base URL for Stripe's OAuth API.</summary>
public static string ConnectBase { get; set; } = DefaultConnectBase;

/// <summary>Gets or sets a value indicating whether telemetry is enabled.</summary>
public static bool EnableTelemetry { get; set; } = false;

/// <summary>Gets or sets the base URL for Stripe's Files API.</summary>
public static string FilesBase { get; set; } = DefaultFilesBase;

Expand Down
8 changes: 8 additions & 0 deletions src/Stripe.net/Infrastructure/Public/SystemNetHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ private static readonly string StripeClientUserAgentString

private readonly System.Net.Http.HttpClient httpClient;

private readonly RequestTelemetry requestTelemetry = new RequestTelemetry();

/// <summary>
/// Initializes a new instance of the <see cref="SystemNetHttpClient"/> class.
/// </summary>
Expand Down Expand Up @@ -60,8 +62,14 @@ public async Task<StripeResponse> MakeRequestAsync(
{
var httpRequest = BuildRequestMessage(request);

this.requestTelemetry.MaybeAddTelemetryHeader(httpRequest.Headers);

var stopwatch = Stopwatch.StartNew();
var response = await this.httpClient.SendAsync(httpRequest, cancellationToken)
.ConfigureAwait(false);
stopwatch.Stop();

this.requestTelemetry.MaybeEnqueueMetrics(response, stopwatch.ElapsedMilliseconds);

var reader = new StreamReader(
await response.Content.ReadAsStreamAsync().ConfigureAwait(false));
Expand Down
213 changes: 213 additions & 0 deletions src/StripeTests/Functional/TelemetryTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
namespace StripeTests
{
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.Protected;
using Newtonsoft.Json.Linq;
using Stripe;
using Xunit;

public class TelemetryTest : BaseStripeTest, IDisposable
{
public TelemetryTest(MockHttpClientFixture mockHttpClientFixture)
: base(mockHttpClientFixture)
{
}

public void Dispose()
{
this.ResetStripeClient();
StripeConfiguration.EnableTelemetry = false;
}

[Fact]
public void TelemetryDisabled()
{
this.ResetStripeClient();
var fakeServer = FakeServer.ForMockHandler(this.MockHttpClientFixture.MockHandler);

StripeConfiguration.EnableTelemetry = false;
var service = new BalanceService();
service.Get();
service.Get();
service.Get();

this.MockHttpClientFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Exactly(3),
ItExpr.Is<HttpRequestMessage>(m =>
!m.Headers.Contains("X-Stripe-Client-Telemetry")),
ItExpr.IsAny<CancellationToken>());
}

[Fact]
public void TelemetryEnabled()
{
this.ResetStripeClient();
var fakeServer = FakeServer.ForMockHandler(this.MockHttpClientFixture.MockHandler);
fakeServer.DelayMilliseconds = 10;

StripeConfiguration.EnableTelemetry = true;
var service = new BalanceService();
service.Get();
fakeServer.DelayMilliseconds = 20;
service.Get();
service.Get();

this.MockHttpClientFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m =>
!m.Headers.Contains("X-Stripe-Client-Telemetry")),
ItExpr.IsAny<CancellationToken>());

this.MockHttpClientFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m =>
TelemetryHeaderMatcher(
m.Headers,
(s) => s == "req_1",
(d) => d >= 10)),
ItExpr.IsAny<CancellationToken>());

this.MockHttpClientFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m =>
TelemetryHeaderMatcher(
m.Headers,
(s) => s == "req_2",
(d) => d >= 20)),
ItExpr.IsAny<CancellationToken>());
}

[Fact]
public async Task TelemetryWorksWithConcurrentRequests()
{
this.ResetStripeClient();
var fakeServer = FakeServer.ForMockHandler(this.MockHttpClientFixture.MockHandler);
fakeServer.DelayMilliseconds = 10;

StripeConfiguration.EnableTelemetry = true;
var service = new BalanceService();

// the first 2 requests will not contain telemetry
await Task.WhenAll(service.GetAsync(), service.GetAsync());

// the following 2 requests will contain telemetry
await Task.WhenAll(service.GetAsync(), service.GetAsync());

this.MockHttpClientFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Exactly(2),
ItExpr.Is<HttpRequestMessage>(m =>
!m.Headers.Contains("X-Stripe-Client-Telemetry")),
ItExpr.IsAny<CancellationToken>());

this.MockHttpClientFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m =>
TelemetryHeaderMatcher(
m.Headers,
(s) => s == "req_1",
(d) => d >= 10)),
ItExpr.IsAny<CancellationToken>());

this.MockHttpClientFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m =>
TelemetryHeaderMatcher(
m.Headers,
(s) => s == "req_2",
(d) => d >= 10)),
ItExpr.IsAny<CancellationToken>());
}

private static bool TelemetryHeaderMatcher(
HttpHeaders headers,
Func<string, bool> requestIdMatcher,
Func<long, bool> durationMatcher)
{
if (!headers.Contains("X-Stripe-Client-Telemetry"))
{
return false;
}

var payload = headers.GetValues("X-Stripe-Client-Telemetry").First();

var deserialized = JToken.Parse(payload);
var requestId = (string)deserialized["last_request_metrics"]["request_id"];
var duration = (long)deserialized["last_request_metrics"]["request_duration_ms"];

return requestIdMatcher(requestId) && durationMatcher(duration);
}

private void ResetStripeClient()
{
this.MockHttpClientFixture.Reset();

var httpClient = new System.Net.Http.HttpClient(
this.MockHttpClientFixture.MockHandler.Object);
var stripeClient = new StripeClient(new Stripe.SystemNetHttpClient(httpClient));

StripeConfiguration.StripeClient = stripeClient;
}

private class FakeServer
{
private object lockObject = new object();

public int DelayMilliseconds { get; set; } = 0;

public int RequestCount { get; protected set; }

public static FakeServer ForMockHandler(Mock<HttpClientHandler> mockHandler)
{
var fakeServer = new FakeServer();
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Returns(fakeServer.NextResponse);
return fakeServer;
}

public async Task<HttpResponseMessage> NextResponse()
{
string requestId;

lock (this.lockObject)
{
this.RequestCount += 1;
requestId = $"req_{this.RequestCount}";
}

await Task.Delay(this.DelayMilliseconds);

return new HttpResponseMessage(HttpStatusCode.OK)
{
Headers = { { "Request-Id", requestId } },
Content = new StringContent("{}", Encoding.UTF8),
};
}
}
}
}
5 changes: 5 additions & 0 deletions src/StripeTests/Infrastructure/Public/StripeResponseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ namespace StripeTests

public class StripeResponseTest : BaseStripeTest
{
public StripeResponseTest(MockHttpClientFixture mockHttpClientFixture)
: base(mockHttpClientFixture)
{
}

/* Most of StripeResponse's methods are helpers for accessing headers. Unfortunately,
* HttpResponseHeaders is a sealed class with no public constructor, which makes it
* ~impossible to write unit tests for StripeResponse. This is why we rely on real
Expand Down

0 comments on commit 29a4624

Please sign in to comment.