diff --git a/src/OpenTelemetry.Instrumentation.Http/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.Http/CHANGELOG.md index 51594927f13..46fc87b0580 100644 --- a/src/OpenTelemetry.Instrumentation.Http/CHANGELOG.md +++ b/src/OpenTelemetry.Instrumentation.Http/CHANGELOG.md @@ -2,10 +2,12 @@ ## Unreleased -* Updated [Http Semantic Conventions](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/trace/semantic_conventions/http.md). +* Updated [Http Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md). This library can emit either old, new, or both attributes. Users can control which attributes are emitted by setting the environment variable `OTEL_SEMCONV_STABILITY_OPT_IN`. + ([#4538](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4538)) + ([#4639](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4639)) ## 1.5.0-beta.1 diff --git a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerDiagnosticListener.cs b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerDiagnosticListener.cs index b7d2458d23b..f8044e6dec1 100644 --- a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerDiagnosticListener.cs +++ b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerDiagnosticListener.cs @@ -179,10 +179,9 @@ public void OnStartActivity(Activity activity, object payload) activity.SetTag(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocolVersion(request.Version)); } - // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/trace/semantic_conventions/http.md + // see the spec https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md if (this.emitNewAttributes) { - activity.SetTag(SemanticConventions.AttributeUrlScheme, request.RequestUri.Scheme); activity.SetTag(SemanticConventions.AttributeHttpRequestMethod, HttpTagHelper.GetNameForHttpMethod(request.Method)); activity.SetTag(SemanticConventions.AttributeServerAddress, request.RequestUri.Host); if (!request.RequestUri.IsDefaultPort) diff --git a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerMetricsDiagnosticListener.cs b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerMetricsDiagnosticListener.cs index 6fe05338b47..0310ea86dad 100644 --- a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerMetricsDiagnosticListener.cs +++ b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerMetricsDiagnosticListener.cs @@ -79,11 +79,10 @@ public override void OnEventWritten(string name, object payload) } } - // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/trace/semantic_conventions/http.md + // see the spec https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md if (this.emitNewAttributes) { tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpRequestMethod, HttpTagHelper.GetNameForHttpMethod(request.Method))); - tags.Add(new KeyValuePair(SemanticConventions.AttributeUrlScheme, request.RequestUri.Scheme)); tags.Add(new KeyValuePair(SemanticConventions.AttributeNetworkProtocolVersion, HttpTagHelper.GetFlavorTagValueFromProtocolVersion(request.Version))); tags.Add(new KeyValuePair(SemanticConventions.AttributeServerAddress, request.RequestUri.Host)); diff --git a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpWebRequestActivitySource.netfx.cs b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpWebRequestActivitySource.netfx.cs index 7150ce8ddde..59928bad4a4 100644 --- a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpWebRequestActivitySource.netfx.cs +++ b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpWebRequestActivitySource.netfx.cs @@ -23,6 +23,7 @@ using System.Runtime.CompilerServices; using OpenTelemetry.Context.Propagation; using OpenTelemetry.Trace; +using static OpenTelemetry.Internal.HttpSemanticConventionHelper; namespace OpenTelemetry.Instrumentation.Http.Implementation { @@ -42,11 +43,14 @@ internal static class HttpWebRequestActivitySource internal static readonly Func> HttpWebRequestHeaderValuesGetter = (request, name) => request.Headers.GetValues(name); internal static readonly Action HttpWebRequestHeaderValuesSetter = (request, name, value) => request.Headers.Add(name, value); - internal static HttpClientInstrumentationOptions Options = new HttpClientInstrumentationOptions(); - private static readonly Version Version = AssemblyName.Version; private static readonly ActivitySource WebRequestActivitySource = new ActivitySource(ActivitySourceName, Version.ToString()); + private static HttpClientInstrumentationOptions options; + + private static bool emitOldAttributes; + private static bool emitNewAttributes; + // Fields for reflection private static FieldInfo connectionGroupListField; private static Type connectionGroupType; @@ -81,6 +85,8 @@ static HttpWebRequestActivitySource() { PrepareReflectionObjects(); PerformInjection(); + + Options = new HttpClientInstrumentationOptions(); } catch (Exception ex) { @@ -89,6 +95,18 @@ static HttpWebRequestActivitySource() } } + internal static HttpClientInstrumentationOptions Options + { + get => options; + set + { + options = value; + + emitOldAttributes = value.HttpSemanticConvention.HasFlag(HttpSemanticConvention.Old); + emitNewAttributes = value.HttpSemanticConvention.HasFlag(HttpSemanticConvention.New); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void AddRequestTagsAndInstrumentRequest(HttpWebRequest request, Activity activity) { @@ -96,16 +114,34 @@ private static void AddRequestTagsAndInstrumentRequest(HttpWebRequest request, A if (activity.IsAllDataRequested) { - activity.SetTag(SemanticConventions.AttributeHttpMethod, request.Method); - activity.SetTag(SemanticConventions.AttributeNetPeerName, request.RequestUri.Host); - if (!request.RequestUri.IsDefaultPort) + // see the spec https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/trace/semantic_conventions/http.md + if (emitOldAttributes) { - activity.SetTag(SemanticConventions.AttributeNetPeerPort, request.RequestUri.Port); + activity.SetTag(SemanticConventions.AttributeHttpMethod, request.Method); + activity.SetTag(SemanticConventions.AttributeNetPeerName, request.RequestUri.Host); + if (!request.RequestUri.IsDefaultPort) + { + activity.SetTag(SemanticConventions.AttributeNetPeerPort, request.RequestUri.Port); + } + + activity.SetTag(SemanticConventions.AttributeHttpScheme, request.RequestUri.Scheme); + activity.SetTag(SemanticConventions.AttributeHttpUrl, HttpTagHelper.GetUriTagValueFromRequestUri(request.RequestUri)); + activity.SetTag(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocolVersion(request.ProtocolVersion)); } - activity.SetTag(SemanticConventions.AttributeHttpScheme, request.RequestUri.Scheme); - activity.SetTag(SemanticConventions.AttributeHttpUrl, HttpTagHelper.GetUriTagValueFromRequestUri(request.RequestUri)); - activity.SetTag(SemanticConventions.AttributeHttpFlavor, HttpTagHelper.GetFlavorTagValueFromProtocolVersion(request.ProtocolVersion)); + // see the spec https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeHttpRequestMethod, request.Method); + activity.SetTag(SemanticConventions.AttributeServerAddress, request.RequestUri.Host); + if (!request.RequestUri.IsDefaultPort) + { + activity.SetTag(SemanticConventions.AttributeServerPort, request.RequestUri.Port); + } + + activity.SetTag(SemanticConventions.AttributeUrlFull, HttpTagHelper.GetUriTagValueFromRequestUri(request.RequestUri)); + activity.SetTag(SemanticConventions.AttributeNetworkProtocolVersion, HttpTagHelper.GetFlavorTagValueFromProtocolVersion(request.ProtocolVersion)); + } try { @@ -123,7 +159,15 @@ private static void AddResponseTags(HttpWebResponse response, Activity activity) { if (activity.IsAllDataRequested) { - activity.SetTag(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)); + if (emitOldAttributes) + { + activity.SetTag(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)); + } + + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode)); + } activity.SetStatus(SpanHelper.ResolveSpanStatusForHttpStatusCode(activity.Kind, (int)response.StatusCode)); @@ -153,7 +197,15 @@ private static void AddExceptionTags(Exception exception, Activity activity) { if (wexc.Response is HttpWebResponse response) { - activity.SetTag(SemanticConventions.AttributeHttpStatusCode, (int)response.StatusCode); + if (emitOldAttributes) + { + activity.SetTag(SemanticConventions.AttributeHttpStatusCode, (int)response.StatusCode); + } + + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeHttpResponseStatusCode, (int)response.StatusCode); + } status = SpanHelper.ResolveSpanStatusForHttpStatusCode(activity.Kind, (int)response.StatusCode); } diff --git a/test/OpenTelemetry.Instrumentation.Http.Tests/HttpWebRequestActivitySourceTestsDupe.netfx.cs b/test/OpenTelemetry.Instrumentation.Http.Tests/HttpWebRequestActivitySourceTestsDupe.netfx.cs new file mode 100644 index 00000000000..ec00ceb6373 --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Http.Tests/HttpWebRequestActivitySourceTestsDupe.netfx.cs @@ -0,0 +1,318 @@ +// +// 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. +// + +#if NETFRAMEWORK +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using OpenTelemetry.Instrumentation.Http.Implementation; +using OpenTelemetry.Tests; +using OpenTelemetry.Trace; +using Xunit; +using static OpenTelemetry.Internal.HttpSemanticConventionHelper; + +namespace OpenTelemetry.Instrumentation.Http.Tests +{ + // Tests for v1.21.0 Semantic Conventions for Http spans + // see the spec https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md + // These tests emit both the new and older attributes. + // This test class can be deleted when this library is GA. + public class HttpWebRequestActivitySourceTestsDupe : IDisposable + { + private readonly IDisposable testServer; + private readonly string testServerHost; + private readonly int testServerPort; + private readonly string hostNameAndPort; + private readonly string netPeerName; + private readonly int netPeerPort; + + static HttpWebRequestActivitySourceTestsDupe() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [SemanticConventionOptInKeyName] = "http/dup" }) + .Build(); + + HttpClientInstrumentationOptions options = new(configuration) + { + EnrichWithHttpWebRequest = (activity, httpWebRequest) => + { + VerifyHeaders(httpWebRequest); + }, + }; + + HttpWebRequestActivitySource.Options = options; + + // Need to touch something in HttpWebRequestActivitySource/Sdk to do the static injection. + GC.KeepAlive(HttpWebRequestActivitySource.Options); + _ = Sdk.SuppressInstrumentation; + } + + public HttpWebRequestActivitySourceTestsDupe() + { + Assert.Null(Activity.Current); + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = false; + + this.testServer = TestHttpServer.RunServer( + ctx => ProcessServerRequest(ctx), + out this.testServerHost, + out this.testServerPort); + + this.hostNameAndPort = $"{this.testServerHost}:{this.testServerPort}"; + this.netPeerName = this.testServerHost; + this.netPeerPort = this.testServerPort; + + void ProcessServerRequest(HttpListenerContext context) + { + string redirects = context.Request.QueryString["redirects"]; + if (!string.IsNullOrWhiteSpace(redirects) && int.TryParse(redirects, out int parsedRedirects) && parsedRedirects > 0) + { + context.Response.Redirect(this.BuildRequestUrl(queryString: $"redirects={--parsedRedirects}")); + context.Response.OutputStream.Close(); + return; + } + + string responseContent; + if (context.Request.QueryString["skipRequestContent"] == null) + { + using StreamReader readStream = new StreamReader(context.Request.InputStream); + + responseContent = readStream.ReadToEnd(); + } + else + { + responseContent = $"{{\"Id\":\"{Guid.NewGuid()}\"}}"; + } + + string responseCode = context.Request.QueryString["responseCode"]; + if (!string.IsNullOrWhiteSpace(responseCode)) + { + context.Response.StatusCode = int.Parse(responseCode); + } + else + { + context.Response.StatusCode = 200; + } + + if (context.Response.StatusCode != 204) + { + using StreamWriter writeStream = new StreamWriter(context.Response.OutputStream); + + writeStream.Write(responseContent); + } + else + { + context.Response.OutputStream.Close(); + } + } + } + + public void Dispose() + { + this.testServer.Dispose(); + } + + /// + /// Test to make sure we get both request and response events. + /// + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("POST", "skipRequestContent=1")] + public async Task TestBasicReceiveAndResponseEvents(string method, string queryString = null) + { + var url = this.BuildRequestUrl(queryString: queryString); + + using var eventRecords = new ActivitySourceRecorder(); + + // Send a random Http request to generate some events + using (var client = new HttpClient()) + { + (method == "GET" + ? await client.GetAsync(url).ConfigureAwait(false) + : await client.PostAsync(url, new StringContent("hello world")).ConfigureAwait(false)).Dispose(); + } + + // We should have exactly one Start and one Stop event + Assert.Equal(2, eventRecords.Records.Count); + Assert.Equal(1, eventRecords.Records.Count(rec => rec.Key == "Start")); + Assert.Equal(1, eventRecords.Records.Count(rec => rec.Key == "Stop")); + + // Check to make sure: The first record must be a request, the next record must be a response. + Activity activity = AssertFirstEventWasStart(eventRecords); + + VerifyActivityStartTags(this.netPeerName, this.netPeerPort, method, url, activity); + + Assert.True(eventRecords.Records.TryDequeue(out var stopEvent)); + Assert.Equal("Stop", stopEvent.Key); + + VerifyActivityStopTags(200, activity); + } + + private static Activity AssertFirstEventWasStart(ActivitySourceRecorder eventRecords) + { + Assert.True(eventRecords.Records.TryDequeue(out KeyValuePair startEvent)); + Assert.Equal("Start", startEvent.Key); + return startEvent.Value; + } + + private static void VerifyHeaders(HttpWebRequest startRequest) + { + var tracestate = startRequest.Headers["tracestate"]; + Assert.Equal("some=state", tracestate); + + var baggage = startRequest.Headers["baggage"]; + Assert.Equal("k=v", baggage); + + var traceparent = startRequest.Headers["traceparent"]; + Assert.NotNull(traceparent); + Assert.Matches("^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$", traceparent); + } + + private static void VerifyActivityStartTags(string netPeerName, int? netPeerPort, string method, string url, Activity activity) + { + // New + Assert.NotNull(activity.TagObjects); + Assert.Equal(method, activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + if (netPeerPort != null) + { + Assert.Equal(netPeerPort, activity.GetTagValue(SemanticConventions.AttributeServerPort)); + } + + Assert.Equal(netPeerName, activity.GetTagValue(SemanticConventions.AttributeServerAddress)); + + Assert.Equal(url, activity.GetTagValue(SemanticConventions.AttributeUrlFull)); + + // Old + Assert.NotNull(activity.TagObjects); + Assert.Equal(method, activity.GetTagValue(SemanticConventions.AttributeHttpMethod)); + if (netPeerPort != null) + { + Assert.Equal(netPeerPort, activity.GetTagValue(SemanticConventions.AttributeNetPeerPort)); + } + + Assert.Equal(netPeerName, activity.GetTagValue(SemanticConventions.AttributeNetPeerName)); + + Assert.Equal(url, activity.GetTagValue(SemanticConventions.AttributeHttpUrl)); + } + + private static void VerifyActivityStopTags(int statusCode, Activity activity) + { + // New + Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpResponseStatusCode)); + + // Old + Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpStatusCode)); + } + + private static void ActivityEnrichment(Activity activity, string method, object obj) + { + switch (method) + { + case "OnStartActivity": + Assert.True(obj is HttpWebRequest); + VerifyHeaders(obj as HttpWebRequest); + break; + + case "OnStopActivity": + Assert.True(obj is HttpWebResponse); + break; + + case "OnException": + Assert.True(obj is Exception); + break; + + default: + break; + } + } + + private static void ValidateBaggage(HttpWebRequest request) + { + string[] baggage = request.Headers["baggage"].Split(','); + + Assert.Equal(3, baggage.Length); + Assert.Contains("key=value", baggage); + Assert.Contains("bad%2Fkey=value", baggage); + Assert.Contains("goodkey=bad%2Fvalue", baggage); + } + + private string BuildRequestUrl(bool useHttps = false, string path = "echo", string queryString = null) + { + return $"{(useHttps ? "https" : "http")}://{this.testServerHost}:{this.testServerPort}/{path}{(string.IsNullOrWhiteSpace(queryString) ? string.Empty : $"?{queryString}")}"; + } + + private void CleanUpActivity() + { + while (Activity.Current != null) + { + Activity.Current.Stop(); + } + } + + /// + /// is a helper class for recording events. + /// + private class ActivitySourceRecorder : IDisposable + { + private readonly Action> onEvent; + private readonly ActivityListener activityListener; + + public ActivitySourceRecorder(Action> onEvent = null, ActivitySamplingResult activitySamplingResult = ActivitySamplingResult.AllDataAndRecorded) + { + this.activityListener = new ActivityListener + { + ShouldListenTo = (activitySource) => activitySource.Name == HttpWebRequestActivitySource.ActivitySourceName, + ActivityStarted = this.ActivityStarted, + ActivityStopped = this.ActivityStopped, + Sample = (ref ActivityCreationOptions options) => activitySamplingResult, + }; + + ActivitySource.AddActivityListener(this.activityListener); + + this.onEvent = onEvent; + } + + public ConcurrentQueue> Records { get; } = new ConcurrentQueue>(); + + public void Dispose() + { + this.activityListener.Dispose(); + } + + public void ActivityStarted(Activity activity) => this.Record("Start", activity); + + public void ActivityStopped(Activity activity) => this.Record("Stop", activity); + + private void Record(string eventName, Activity activity) + { + var record = new KeyValuePair(eventName, activity); + + this.Records.Enqueue(record); + this.onEvent?.Invoke(record); + } + } + } +} +#endif diff --git a/test/OpenTelemetry.Instrumentation.Http.Tests/HttpWebRequestActivitySourceTestsNew.netfx.cs b/test/OpenTelemetry.Instrumentation.Http.Tests/HttpWebRequestActivitySourceTestsNew.netfx.cs new file mode 100644 index 00000000000..7e22da99fea --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.Http.Tests/HttpWebRequestActivitySourceTestsNew.netfx.cs @@ -0,0 +1,302 @@ +// +// 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. +// + +#if NETFRAMEWORK +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using OpenTelemetry.Instrumentation.Http.Implementation; +using OpenTelemetry.Tests; +using OpenTelemetry.Trace; +using Xunit; +using static OpenTelemetry.Internal.HttpSemanticConventionHelper; + +namespace OpenTelemetry.Instrumentation.Http.Tests +{ + // Tests for v1.21.0 Semantic Conventions for Http spans + // see the spec https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md + // These tests emit the new attributes. + // This test class can replace the other class when this library is GA. + public class HttpWebRequestActivitySourceTestsNew : IDisposable + { + private readonly IDisposable testServer; + private readonly string testServerHost; + private readonly int testServerPort; + private readonly string hostNameAndPort; + private readonly string netPeerName; + private readonly int netPeerPort; + + static HttpWebRequestActivitySourceTestsNew() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [SemanticConventionOptInKeyName] = "http" }) + .Build(); + + HttpClientInstrumentationOptions options = new(configuration) + { + EnrichWithHttpWebRequest = (activity, httpWebRequest) => + { + VerifyHeaders(httpWebRequest); + }, + }; + + HttpWebRequestActivitySource.Options = options; + + // Need to touch something in HttpWebRequestActivitySource/Sdk to do the static injection. + GC.KeepAlive(HttpWebRequestActivitySource.Options); + _ = Sdk.SuppressInstrumentation; + } + + public HttpWebRequestActivitySourceTestsNew() + { + Assert.Null(Activity.Current); + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = false; + + this.testServer = TestHttpServer.RunServer( + ctx => ProcessServerRequest(ctx), + out this.testServerHost, + out this.testServerPort); + + this.hostNameAndPort = $"{this.testServerHost}:{this.testServerPort}"; + this.netPeerName = this.testServerHost; + this.netPeerPort = this.testServerPort; + + void ProcessServerRequest(HttpListenerContext context) + { + string redirects = context.Request.QueryString["redirects"]; + if (!string.IsNullOrWhiteSpace(redirects) && int.TryParse(redirects, out int parsedRedirects) && parsedRedirects > 0) + { + context.Response.Redirect(this.BuildRequestUrl(queryString: $"redirects={--parsedRedirects}")); + context.Response.OutputStream.Close(); + return; + } + + string responseContent; + if (context.Request.QueryString["skipRequestContent"] == null) + { + using StreamReader readStream = new StreamReader(context.Request.InputStream); + + responseContent = readStream.ReadToEnd(); + } + else + { + responseContent = $"{{\"Id\":\"{Guid.NewGuid()}\"}}"; + } + + string responseCode = context.Request.QueryString["responseCode"]; + if (!string.IsNullOrWhiteSpace(responseCode)) + { + context.Response.StatusCode = int.Parse(responseCode); + } + else + { + context.Response.StatusCode = 200; + } + + if (context.Response.StatusCode != 204) + { + using StreamWriter writeStream = new StreamWriter(context.Response.OutputStream); + + writeStream.Write(responseContent); + } + else + { + context.Response.OutputStream.Close(); + } + } + } + + public void Dispose() + { + this.testServer.Dispose(); + } + + /// + /// Test to make sure we get both request and response events. + /// + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("POST", "skipRequestContent=1")] + public async Task TestBasicReceiveAndResponseEvents(string method, string queryString = null) + { + var url = this.BuildRequestUrl(queryString: queryString); + + using var eventRecords = new ActivitySourceRecorder(); + + // Send a random Http request to generate some events + using (var client = new HttpClient()) + { + (method == "GET" + ? await client.GetAsync(url).ConfigureAwait(false) + : await client.PostAsync(url, new StringContent("hello world")).ConfigureAwait(false)).Dispose(); + } + + // We should have exactly one Start and one Stop event + Assert.Equal(2, eventRecords.Records.Count); + Assert.Equal(1, eventRecords.Records.Count(rec => rec.Key == "Start")); + Assert.Equal(1, eventRecords.Records.Count(rec => rec.Key == "Stop")); + + // Check to make sure: The first record must be a request, the next record must be a response. + Activity activity = AssertFirstEventWasStart(eventRecords); + + VerifyActivityStartTags(this.netPeerName, this.netPeerPort, method, url, activity); + + Assert.True(eventRecords.Records.TryDequeue(out var stopEvent)); + Assert.Equal("Stop", stopEvent.Key); + + VerifyActivityStopTags(200, activity); + } + + private static Activity AssertFirstEventWasStart(ActivitySourceRecorder eventRecords) + { + Assert.True(eventRecords.Records.TryDequeue(out KeyValuePair startEvent)); + Assert.Equal("Start", startEvent.Key); + return startEvent.Value; + } + + private static void VerifyHeaders(HttpWebRequest startRequest) + { + var tracestate = startRequest.Headers["tracestate"]; + Assert.Equal("some=state", tracestate); + + var baggage = startRequest.Headers["baggage"]; + Assert.Equal("k=v", baggage); + + var traceparent = startRequest.Headers["traceparent"]; + Assert.NotNull(traceparent); + Assert.Matches("^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$", traceparent); + } + + private static void VerifyActivityStartTags(string netPeerName, int? netPeerPort, string method, string url, Activity activity) + { + Assert.NotNull(activity.TagObjects); + Assert.Equal(method, activity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + if (netPeerPort != null) + { + Assert.Equal(netPeerPort, activity.GetTagValue(SemanticConventions.AttributeServerPort)); + } + + Assert.Equal(netPeerName, activity.GetTagValue(SemanticConventions.AttributeServerAddress)); + + Assert.Equal(url, activity.GetTagValue(SemanticConventions.AttributeUrlFull)); + } + + private static void VerifyActivityStopTags(int statusCode, Activity activity) + { + Assert.Equal(statusCode, activity.GetTagValue(SemanticConventions.AttributeHttpResponseStatusCode)); + } + + private static void ActivityEnrichment(Activity activity, string method, object obj) + { + switch (method) + { + case "OnStartActivity": + Assert.True(obj is HttpWebRequest); + VerifyHeaders(obj as HttpWebRequest); + + break; + + case "OnStopActivity": + Assert.True(obj is HttpWebResponse); + break; + + case "OnException": + Assert.True(obj is Exception); + break; + + default: + break; + } + } + + private static void ValidateBaggage(HttpWebRequest request) + { + string[] baggage = request.Headers["baggage"].Split(','); + + Assert.Equal(3, baggage.Length); + Assert.Contains("key=value", baggage); + Assert.Contains("bad%2Fkey=value", baggage); + Assert.Contains("goodkey=bad%2Fvalue", baggage); + } + + private string BuildRequestUrl(bool useHttps = false, string path = "echo", string queryString = null) + { + return $"{(useHttps ? "https" : "http")}://{this.testServerHost}:{this.testServerPort}/{path}{(string.IsNullOrWhiteSpace(queryString) ? string.Empty : $"?{queryString}")}"; + } + + private void CleanUpActivity() + { + while (Activity.Current != null) + { + Activity.Current.Stop(); + } + } + + /// + /// is a helper class for recording events. + /// + private class ActivitySourceRecorder : IDisposable + { + private readonly Action> onEvent; + private readonly ActivityListener activityListener; + + public ActivitySourceRecorder(Action> onEvent = null, ActivitySamplingResult activitySamplingResult = ActivitySamplingResult.AllDataAndRecorded) + { + this.activityListener = new ActivityListener + { + ShouldListenTo = (activitySource) => activitySource.Name == HttpWebRequestActivitySource.ActivitySourceName, + ActivityStarted = this.ActivityStarted, + ActivityStopped = this.ActivityStopped, + Sample = (ref ActivityCreationOptions options) => activitySamplingResult, + }; + + ActivitySource.AddActivityListener(this.activityListener); + + this.onEvent = onEvent; + } + + public ConcurrentQueue> Records { get; } = new ConcurrentQueue>(); + + public void Dispose() + { + this.activityListener.Dispose(); + } + + public void ActivityStarted(Activity activity) => this.Record("Start", activity); + + public void ActivityStopped(Activity activity) => this.Record("Stop", activity); + + private void Record(string eventName, Activity activity) + { + var record = new KeyValuePair(eventName, activity); + + this.Records.Enqueue(record); + this.onEvent?.Invoke(record); + } + } + } +} +#endif