Skip to content

Commit

Permalink
HttpClient http.client.duration metric (#2243)
Browse files Browse the repository at this point in the history
  • Loading branch information
alanwest authored Aug 10, 2021
1 parent 71dcc6b commit 67b6a83
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 0 deletions.
53 changes: 53 additions & 0 deletions src/OpenTelemetry.Instrumentation.Http/HttpClientMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// <copyright file="HttpClientMetrics.cs" company="OpenTelemetry Authors">
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>

using System;
using System.Diagnostics.Metrics;
using System.Reflection;
using OpenTelemetry.Instrumentation.Http.Implementation;

namespace OpenTelemetry.Instrumentation.Http
{
/// <summary>
/// HttpClient instrumentation.
/// </summary>
internal class HttpClientMetrics : IDisposable
{
internal static readonly AssemblyName AssemblyName = typeof(HttpClientMetrics).Assembly.GetName();
internal static readonly string InstrumentationName = AssemblyName.Name;
internal static readonly string InstrumentationVersion = AssemblyName.Version.ToString();

private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber;
private readonly Meter meter;

/// <summary>
/// Initializes a new instance of the <see cref="HttpClientMetrics"/> class.
/// </summary>
public HttpClientMetrics()
{
this.meter = new Meter(InstrumentationName, InstrumentationVersion);
this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber(new HttpHandlerMetricsDiagnosticListener("HttpHandlerDiagnosticListener", this.meter), null);
this.diagnosticSourceSubscriber.Subscribe();
}

/// <inheritdoc/>
public void Dispose()
{
this.diagnosticSourceSubscriber?.Dispose();
this.meter?.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// <copyright file="HttpHandlerMetricsDiagnosticListener.cs" company="OpenTelemetry Authors">
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>

using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Net.Http;
using OpenTelemetry.Trace;

namespace OpenTelemetry.Instrumentation.Http.Implementation
{
internal class HttpHandlerMetricsDiagnosticListener : ListenerHandler
{
private readonly PropertyFetcher<HttpResponseMessage> stopResponseFetcher = new PropertyFetcher<HttpResponseMessage>("Response");
private readonly Histogram<double> httpClientDuration;

public HttpHandlerMetricsDiagnosticListener(string name, Meter meter)
: base(name)
{
this.httpClientDuration = meter.CreateHistogram<double>("http.client.duration", "milliseconds", "measure the duration of the outbound HTTP request");
}

public override void OnStopActivity(Activity activity, object payload)
{
if (Sdk.SuppressInstrumentation)
{
return;
}

if (this.stopResponseFetcher.TryFetch(payload, out HttpResponseMessage response) && response != null)
{
var request = response.RequestMessage;

// TODO: This is just a minimal set of attributes. See the spec for additional attributes:
// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md#http-client
var tags = new KeyValuePair<string, object>[]
{
new KeyValuePair<string, object>(SemanticConventions.AttributeHttpMethod, HttpTagHelper.GetNameForHttpMethod(request.Method)),
new KeyValuePair<string, object>(SemanticConventions.AttributeHttpScheme, request.RequestUri.Scheme),
new KeyValuePair<string, object>(SemanticConventions.AttributeHttpStatusCode, (int)response.StatusCode),
new KeyValuePair<string, object>(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocolVersion(request.Version)),
};

this.httpClientDuration.Record(activity.Duration.TotalMilliseconds, tags);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// <copyright file="MeterProviderBuilderExtensions.cs" company="OpenTelemetry Authors">
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>

using System;
using OpenTelemetry.Instrumentation.Http;

namespace OpenTelemetry.Metrics
{
/// <summary>
/// Extension methods to simplify registering of HttpClient instrumentation.
/// </summary>
public static class MeterProviderBuilderExtensions
{
/// <summary>
/// Enables HttpClient instrumentation.
/// </summary>
/// <param name="builder"><see cref="MeterProviderBuilder"/> being configured.</param>
/// <returns>The instance of <see cref="MeterProviderBuilder"/> to chain the calls.</returns>
public static MeterProviderBuilder AddHttpClientInstrumentation(
this MeterProviderBuilder builder)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}

// TODO: Implement an IDeferredMeterProviderBuilder

// TODO: Handle HttpClientInstrumentationOptions
// SetHttpFlavor - seems like this would be handled by views
// Filter - makes sense for metric instrumentation
// Enrich - do we want a similar kind of functionality for metrics?
// RecordException - probably doesn't make sense for metric instrumentation

var instrumentation = new HttpClientMetrics();
builder.AddSource(HttpClientMetrics.InstrumentationName);
return builder.AddInstrumentation(() => instrumentation);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
using System.Threading.Tasks;
using Moq;
using Newtonsoft.Json;
using OpenTelemetry.Metrics;
using OpenTelemetry.Tests;
using OpenTelemetry.Trace;
using Xunit;
Expand Down Expand Up @@ -53,6 +54,23 @@ public async Task HttpOutCallsAreCollectedSuccessfullyAsync(HttpTestData.HttpOut
var processor = new Mock<BaseProcessor<Activity>>();
tc.Url = HttpTestData.NormalizeValues(tc.Url, host, port);

var metricItems = new List<MetricItem>();
var metricExporter = new TestExporter<MetricItem>(ProcessExport);

void ProcessExport(Batch<MetricItem> batch)
{
foreach (var metricItem in batch)
{
metricItems.Add(metricItem);
}
}

var metricProcessor = new PullMetricProcessor(metricExporter, true);
var meterProvider = Sdk.CreateMeterProviderBuilder()
.AddHttpClientInstrumentation()
.AddMetricProcessor(metricProcessor)
.Build();

using (serverLifeTime)

using (Sdk.CreateTracerProviderBuilder()
Expand Down Expand Up @@ -91,6 +109,15 @@ public async Task HttpOutCallsAreCollectedSuccessfullyAsync(HttpTestData.HttpOut
}
}

// Invokes the TestExporter which will invoke ProcessExport
metricProcessor.PullRequest();

meterProvider.Dispose();

var requestMetrics = metricItems
.SelectMany(item => item.Metrics.Where(metric => metric.Name == "http.client.duration"))
.ToArray();

Assert.Equal(5, processor.Invocations.Count); // SetParentProvider/OnStart/OnEnd/OnShutdown/Dispose called.
var activity = (Activity)processor.Invocations[2].Arguments[0];

Expand Down Expand Up @@ -122,6 +149,30 @@ public async Task HttpOutCallsAreCollectedSuccessfullyAsync(HttpTestData.HttpOut
{
Assert.Single(activity.Events.Where(evt => evt.Name.Equals("exception")));
}

if (tc.ResponseExpected)
{
Assert.Single(requestMetrics);

var metric = requestMetrics[0] as IHistogramMetric;
Assert.NotNull(metric);
Assert.Equal(1L, metric.PopulationCount);
Assert.Equal(activity.Duration.TotalMilliseconds, metric.PopulationSum);

var method = new KeyValuePair<string, object>(SemanticConventions.AttributeHttpMethod, tc.Method);
var scheme = new KeyValuePair<string, object>(SemanticConventions.AttributeHttpScheme, "http");
var statusCode = new KeyValuePair<string, object>(SemanticConventions.AttributeHttpStatusCode, tc.ResponseCode == 0 ? 200 : tc.ResponseCode);
var flavor = new KeyValuePair<string, object>(SemanticConventions.AttributeHttpFlavor, "2.0");
Assert.Contains(method, metric.Attributes);
Assert.Contains(scheme, metric.Attributes);
Assert.Contains(statusCode, metric.Attributes);
Assert.Contains(flavor, metric.Attributes);
Assert.Equal(4, metric.Attributes.Length);
}
else
{
Assert.Empty(requestMetrics);
}
}

[Fact]
Expand All @@ -135,6 +186,7 @@ public async Task DebugIndividualTestAsync()
""method"": ""GET"",
""url"": ""http://{host}:{port}/"",
""responseCode"": 399,
""responseExpected"": true,
""spanName"": ""HTTP GET"",
""spanStatus"": ""UNSET"",
""spanKind"": ""Client"",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<Compile Include="$(RepoRoot)\test\OpenTelemetry.Tests\Shared\TestHttpServer.cs" Link="TestHttpServer.cs" />
<Compile Include="$(RepoRoot)\test\OpenTelemetry.Tests\Shared\EventSourceTestHelper.cs" Link="EventSourceTestHelper.cs" />
<Compile Include="$(RepoRoot)\test\OpenTelemetry.Tests\Shared\TestEventListener.cs" Link="TestEventListener.cs" />
<Compile Include="$(RepoRoot)\test\OpenTelemetry.Tests\Shared\TestExporter.cs" Link="TestExporter.cs" />
<Compile Include="$(RepoRoot)\test\OpenTelemetry.Tests\Shared\InMemoryEventListener.cs" Link="InMemoryEventListener.cs" />
</ItemGroup>

Expand Down

0 comments on commit 67b6a83

Please sign in to comment.