Skip to content

Commit

Permalink
Add enrichment & filter for http client metrics
Browse files Browse the repository at this point in the history
  • Loading branch information
Maksim Ivanyuk authored and hayhay27 committed Sep 26, 2023
1 parent 48b4a8c commit fe8a089
Show file tree
Hide file tree
Showing 5 changed files with 104 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@

namespace OpenTelemetry.Instrumentation.Http;

internal sealed class HttpClientMetricInstrumentationOptions
/// <summary>
/// Options for HttpClient instrumentation.
/// </summary>
public class HttpClientMetricInstrumentationOptions
{
internal readonly HttpSemanticConvention HttpSemanticConvention;

Expand All @@ -38,4 +41,40 @@ internal HttpClientMetricInstrumentationOptions(IConfiguration configuration)

this.HttpSemanticConvention = GetSemanticConventionOptIn(configuration);
}

#if NETSTANDARD2_0 || NET6_0_OR_GREATER
/// <summary>
/// Delegate for enrichment of recorded metric with additional tags.
/// </summary>
/// <param name="name">The name of the metric being enriched.</param>
/// <param name="request"><see cref="HttpRequestMessage"/>: the HttpRequestMessage object.</param>
/// <param name="response"><see cref="HttpResponseMessage"/>: the HttpResponseMessage object.</param>
/// <param name="tags"><see cref="TagList"/>: List of current tags. You can add additional tags to this list. </param>
public delegate void HttpClientMetricEnrichmentFunc(string name, HttpRequestMessage request, HttpResponseMessage response, ref TagList tags);

/// <summary>
/// Gets or sets a filter function that determines whether or not to
/// collect telemetry on a per request basis.
/// </summary>
/// <remarks>
/// Notes:
/// <list type="bullet">
/// <item>The first parameter is the name of the metric being
/// filtered.</item>
/// <item>The return value for the filter function is interpreted as:
/// <list type="bullet">
/// <item>If filter returns <see langword="true" />, the request is
/// collected.</item>
/// <item>If filter returns <see langword="false" /> or throws an
/// exception the request is NOT collected.</item>
/// </list></item>
/// </list>
/// </remarks>
public Func<string, HttpRequestMessage, bool> Filter { get; set; }

/// <summary>
/// Gets or sets an function to enrich a recorded metric with additional custom tags.
/// </summary>
public HttpClientMetricEnrichmentFunc Enrich { get; set; }
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ namespace OpenTelemetry.Instrumentation.Http.Implementation;
internal sealed class HttpHandlerMetricsDiagnosticListener : ListenerHandler
{
internal const string OnStopEvent = "System.Net.Http.HttpRequestOut.Stop";
internal const string EventName = "OnStopActivity";
private const string HttpClientDurationMetricName = "http.client.duration";

private static readonly PropertyFetcher<HttpRequestMessage> StopRequestFetcher = new("Request");
private static readonly PropertyFetcher<HttpResponseMessage> StopResponseFetcher = new("Response");
Expand All @@ -41,7 +43,7 @@ internal sealed class HttpHandlerMetricsDiagnosticListener : ListenerHandler
public HttpHandlerMetricsDiagnosticListener(string name, Meter meter, HttpClientMetricInstrumentationOptions options)
: base(name)
{
this.httpClientDuration = meter.CreateHistogram<double>("http.client.duration", "ms", "Measures the duration of outbound HTTP requests.");
this.httpClientDuration = meter.CreateHistogram<double>(HttpClientDurationMetricName, "ms", "Measures the duration of outbound HTTP requests.");
this.options = options;

this.emitOldAttributes = this.options.HttpSemanticConvention.HasFlag(HttpSemanticConvention.Old);
Expand All @@ -62,6 +64,23 @@ public override void OnEventWritten(string name, object payload)
if (TryFetchRequest(payload, out HttpRequestMessage request))
{
TagList tags = default;
HttpResponseMessage response = null;

#if NETSTANDARD2_0 || NET6_0_OR_GREATER
try
{
if (this.options.Filter?.Invoke(HttpClientDurationMetricName, request) == false)
{
HttpInstrumentationEventSource.Log.RequestIsFilteredOut(EventName);
return;
}
}
catch (Exception ex)
{
HttpInstrumentationEventSource.Log.RequestFilterException(ex);
return;
}
#endif

// see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/trace/semantic_conventions/http.md
if (this.emitOldAttributes)
Expand All @@ -76,7 +95,7 @@ public override void OnEventWritten(string name, object payload)
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeNetPeerPort, request.RequestUri.Port));
}

if (TryFetchResponse(payload, out HttpResponseMessage response))
if (TryFetchResponse(payload, out response))
{
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)));
}
Expand All @@ -94,12 +113,26 @@ public override void OnEventWritten(string name, object payload)
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeServerPort, request.RequestUri.Port));
}

if (TryFetchResponse(payload, out HttpResponseMessage response))
if (TryFetchResponse(payload, out response))
{
tags.Add(new KeyValuePair<string, object>(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)));
}
}

#if NETSTANDARD2_0 || NET6_0_OR_GREATER
if (this.options.Enrich != null)
{
try
{
this.options.Enrich(HttpClientDurationMetricName, request, response, ref tags);
}
catch (Exception ex)
{
HttpInstrumentationEventSource.Log.EnrichmentException(ex);
}
}
#endif

// We are relying here on HttpClient library to set duration before writing the stop event.
// https://github.com/dotnet/runtime/blob/90603686d314147017c8bbe1fa8965776ce607d0/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs#L178
// TODO: Follow up with .NET team if we can continue to rely on this behavior.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ public static class MeterProviderBuilderExtensions
/// Enables HttpClient instrumentation.
/// </summary>
/// <param name="builder"><see cref="MeterProviderBuilder"/> being configured.</param>
/// <param name="configure">Callback action for configuring <see cref="HttpClientMetricInstrumentationOptions"/>.</param>
/// <returns>The instance of <see cref="MeterProviderBuilder"/> to chain the calls.</returns>
public static MeterProviderBuilder AddHttpClientInstrumentation(
this MeterProviderBuilder builder)
this MeterProviderBuilder builder, Action<HttpClientMetricInstrumentationOptions> configure = null)
{
Guard.ThrowIfNull(builder);

Expand All @@ -43,6 +44,11 @@ public static MeterProviderBuilder AddHttpClientInstrumentation(
builder.ConfigureServices(services =>
{
services.RegisterOptionsFactory(configuration => new HttpClientMetricInstrumentationOptions(configuration));

if (configure != null)
{
services.Configure(configure);
}
});

// TODO: Implement an IDeferredMeterProviderBuilder
Expand Down
24 changes: 19 additions & 5 deletions test/OpenTelemetry.Instrumentation.Http.Tests/HttpClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public async Task HttpOutCallsAreCollectedSuccessfullyAsync(HttpTestData.HttpOut
bool enrichWithHttpResponseMessageCalled = false;
bool enrichWithExceptionCalled = false;

bool clientFilterCalled = false;

using var serverLifeTime = TestHttpServer.RunServer(
(ctx) =>
{
Expand All @@ -57,10 +59,19 @@ public async Task HttpOutCallsAreCollectedSuccessfullyAsync(HttpTestData.HttpOut
var metrics = new List<Metric>();

var meterProvider = Sdk.CreateMeterProviderBuilder()
.AddHttpClientInstrumentation()
.AddHttpClientInstrumentation(o =>
{
o.Filter = (_, _) => { return clientFilterCalled = true; };
o.Enrich = Enrich;
})
.AddInMemoryExporter(metrics)
.Build();

static void Enrich(string name, HttpRequestMessage req, HttpResponseMessage res, ref TagList tags)
{
tags.Add("custom.label", 123);
}

using (Sdk.CreateTracerProviderBuilder()
.AddHttpClientInstrumentation((opt) =>
{
Expand Down Expand Up @@ -162,12 +173,13 @@ public async Task HttpOutCallsAreCollectedSuccessfullyAsync(HttpTestData.HttpOut
#if NETFRAMEWORK
Assert.Empty(requestMetrics);
#else
Assert.Single(requestMetrics);
var metric = Assert.Single(requestMetrics);

var metric = requestMetrics[0];
Assert.NotNull(metric);
Assert.True(metric.MetricType == MetricType.Histogram);

Assert.True(clientFilterCalled);

var metricPoints = new List<MetricPoint>();
foreach (var p in metric.GetMetricPoints())
{
Expand Down Expand Up @@ -196,20 +208,22 @@ public async Task HttpOutCallsAreCollectedSuccessfullyAsync(HttpTestData.HttpOut
var flavor = new KeyValuePair<string, object>(SemanticConventions.AttributeHttpFlavor, "2.0");
var hostName = new KeyValuePair<string, object>(SemanticConventions.AttributeNetPeerName, tc.ResponseExpected ? host : "sdlfaldfjalkdfjlkajdflkajlsdjf");
var portNumber = new KeyValuePair<string, object>(SemanticConventions.AttributeNetPeerPort, port);
var customLabel = new KeyValuePair<string, object>("custom.label", 123);
Assert.Contains(hostName, attributes);
Assert.Contains(portNumber, attributes);
Assert.Contains(method, attributes);
Assert.Contains(scheme, attributes);
Assert.Contains(flavor, attributes);
Assert.Contains(customLabel, attributes);
if (tc.ResponseExpected)
{
Assert.Contains(statusCode, attributes);
Assert.Equal(6, attributes.Length);
Assert.Equal(7, attributes.Length);
}
else
{
Assert.DoesNotContain(statusCode, attributes);
Assert.Equal(5, attributes.Length);
Assert.Equal(6, attributes.Length);
}
#endif
}
Expand Down
2 changes: 2 additions & 0 deletions test/OpenTelemetry.Instrumentation.Http.Tests/HttpTestData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,7 @@ public class HttpOutTestCase
public bool? SpanStatusHasDescription { get; set; }

public Dictionary<string, string> SpanAttributes { get; set; }

public override string ToString() => this.Name;
}
}

0 comments on commit fe8a089

Please sign in to comment.