diff --git a/examples/Console/Program.cs b/examples/Console/Program.cs index 81572e68317..bd1200338d7 100644 --- a/examples/Console/Program.cs +++ b/examples/Console/Program.cs @@ -33,6 +33,7 @@ public class Program /// dotnet run -p Examples.Console.csproj jaeger -h localhost -p 6831 /// dotnet run -p Examples.Console.csproj prometheus -i 15 -p 9184 -d 2 /// dotnet run -p Examples.Console.csproj otlp -e "http://localhost:4317" + /// dotnet run -p Examples.Console.csproj otlphttp -e "http://localhost:4318" /// dotnet run -p Examples.Console.csproj zpages /// dotnet run -p Examples.Console.csproj metrics --help /// @@ -42,7 +43,7 @@ public class Program /// Arguments from command line. public static void Main(string[] args) { - Parser.Default.ParseArguments(args) + Parser.Default.ParseArguments(args) .MapResult( (JaegerOptions options) => TestJaegerExporter.Run(options.Host, options.Port), (ZipkinOptions options) => TestZipkinExporter.Run(options.Uri), @@ -56,6 +57,7 @@ public static void Main(string[] args) (OpenTelemetryShimOptions options) => TestOTelShimWithConsoleExporter.Run(options), (OpenTracingShimOptions options) => TestOpenTracingShim.Run(options), (OtlpOptions options) => TestOtlpExporter.Run(options.Endpoint), + (OtlpHttpOptions options) => TestOtlpHttpExporter.Run(options.Endpoint), (InMemoryOptions options) => TestInMemoryExporter.Run(options), errs => 1); } @@ -165,6 +167,13 @@ internal class OtlpOptions public string Endpoint { get; set; } } + [Verb("otlphttp", HelpText = "Specify the options required to test OpenTelemetry Protocol (OTLP) over HTTP")] + internal class OtlpHttpOptions + { + [Option('e', "endpoint", HelpText = "Target to which the exporter is going to send traces or metrics", Default = "http://localhost:4318")] + public string Endpoint { get; set; } + } + [Verb("inmemory", HelpText = "Specify the options required to test InMemory Exporter")] internal class InMemoryOptions { diff --git a/examples/Console/TestOtlpHttpExporter.cs b/examples/Console/TestOtlpHttpExporter.cs new file mode 100644 index 00000000000..b442b7cca61 --- /dev/null +++ b/examples/Console/TestOtlpHttpExporter.cs @@ -0,0 +1,56 @@ +// +// 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. +// + +using System; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Examples.Console +{ + internal static class TestOtlpHttpExporter + { + internal static object Run(string endpoint) + { + // Adding the OtlpExporter creates a GrpcChannel. + // This switch must be set before creating a GrpcChannel/HttpClient when calling an insecure gRPC service. + // See: https://docs.microsoft.com/aspnet/core/grpc/troubleshoot#call-insecure-grpc-services-with-net-core-client + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + + // Enable OpenTelemetry for the sources "Samples.SampleServer" and "Samples.SampleClient" + // and use OTLP over HTTP exporter. + using var openTelemetry = Sdk.CreateTracerProviderBuilder() + .AddSource("Samples.SampleClient", "Samples.SampleServer") + .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("otlphttp-test")) + .AddOtlpHttpExporter(opt => opt.Endpoint = new Uri(endpoint)) + .Build(); + + // The above line is required only in Applications + // which decide to use OpenTelemetry. + using (var sample = new InstrumentationWithActivitySource()) + { + sample.Start(); + + System.Console.WriteLine("Traces are being created and exported" + + "to the OpenTelemetry Collector in the background. " + + "Press ENTER to stop."); + System.Console.ReadLine(); + } + + return null; + } + } +} diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/BaseOtlpHttpExporter.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/BaseOtlpHttpExporter.cs new file mode 100644 index 00000000000..3b35ffc5045 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/BaseOtlpHttpExporter.cs @@ -0,0 +1,90 @@ +// +// 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. +// + +using System; +using System.Collections.Generic; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; +using OtlpResource = Opentelemetry.Proto.Resource.V1; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol +{ + /// + /// Implements exporter that exports telemetry objects over OTLP/HTTP. + /// + /// The type of telemetry object to be exported. + public abstract class BaseOtlpHttpExporter : BaseExporter + where T : class + { + private OtlpResource.Resource processResource; + private bool disposedValue; // To avoid duplicate dispose calls + + /// + /// Initializes a new instance of the class. + /// + /// The for configuring the exporter. + /// The used http requests. + protected BaseOtlpHttpExporter(OtlpExporterOptions options, IHttpHandler httpHandler = null) + { + this.Options = options ?? throw new ArgumentNullException(nameof(options)); + this.Headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); + if (this.Options.TimeoutMilliseconds <= 0) + { + throw new ArgumentException("Timeout value provided is not a positive number.", nameof(this.Options.TimeoutMilliseconds)); + } + + this.HttpHandler = httpHandler ?? new HttpHandler(TimeSpan.FromMilliseconds(this.Options.TimeoutMilliseconds)); + } + + internal OtlpResource.Resource ProcessResource => this.processResource ??= this.ParentProvider.GetResource().ToOtlpResource(); + + internal OtlpExporterOptions Options { get; } + + internal IReadOnlyDictionary Headers { get; } + + internal IHttpHandler HttpHandler { get; } + + /// + protected override void Dispose(bool disposing) + { + if (!this.disposedValue) + { + if (disposing) + { + this.HttpHandler?.Dispose(); + } + + this.disposedValue = true; + } + + base.Dispose(disposing); + } + + /// + protected override bool OnShutdown(int timeoutMilliseconds) + { + try + { + this.HttpHandler.CancelPendingRequests(); + return true; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.ExportMethodException(ex); + return false; + } + } + } +} diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/HttpHandler.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/HttpHandler.cs new file mode 100644 index 00000000000..db8f0071ffb --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/HttpHandler.cs @@ -0,0 +1,54 @@ +// +// 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. +// + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation +{ + /// + /// Class decorating . + /// + internal class HttpHandler : IHttpHandler + { + internal readonly HttpClient HttpClient; + + public HttpHandler(TimeSpan timeout) + { + this.HttpClient = new HttpClient + { + Timeout = timeout, + }; + } + + public void CancelPendingRequests() + { + this.HttpClient.CancelPendingRequests(); + } + + public void Dispose() + { + this.HttpClient.Dispose(); + } + + public async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return await this.HttpClient.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/IHttpHandler.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/IHttpHandler.cs new file mode 100644 index 00000000000..c2e55f4a85a --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/IHttpHandler.cs @@ -0,0 +1,42 @@ +// +// 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. +// + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation +{ + /// + /// Interface partialy exposing methods. + /// + public interface IHttpHandler : IDisposable + { + /// + /// Cancel all pending requests on this instance. + /// + void CancelPendingRequests(); + + /// + /// Send an HTTP request as an asynchronous operation. + /// + /// The HTTP request message to send. + /// The cancellation token to cancel operation. + /// Result of the export operation. + Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default); + } +} diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsCommonExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsCommonExtensions.cs new file mode 100644 index 00000000000..3e7cfe07b07 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsCommonExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol +{ + internal static class OtlpExporterOptionsCommonExtensions + { + internal static THeaders GetHeaders(this OtlpExporterOptions options, Action addHeader) + where THeaders : new() + { + var optionHeaders = options.Headers; + var headers = new THeaders(); + if (!string.IsNullOrEmpty(optionHeaders)) + { + Array.ForEach( + optionHeaders.Split(','), + (pair) => + { + // Specify the maximum number of substrings to return to 2 + // This treats everything that follows the first `=` in the string as the value to be added for the metadata key + var keyValueData = pair.Split(new char[] { '=' }, 2); + if (keyValueData.Length != 2) + { + throw new ArgumentException("Headers provided in an invalid format."); + } + + var key = keyValueData[0].Trim(); + var value = keyValueData[1].Trim(); + addHeader(headers, key, value); + }); + } + + return headers; + } + } +} diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsGrpcExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsGrpcExtensions.cs index f3750e654db..30de5313c9e 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsGrpcExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsGrpcExtensions.cs @@ -54,29 +54,7 @@ public static Channel CreateChannel(this OtlpExporterOptions options) public static Metadata GetMetadataFromHeaders(this OtlpExporterOptions options) { - var headers = options.Headers; - var metadata = new Metadata(); - if (!string.IsNullOrEmpty(headers)) - { - Array.ForEach( - headers.Split(','), - (pair) => - { - // Specify the maximum number of substrings to return to 2 - // This treats everything that follows the first `=` in the string as the value to be added for the metadata key - var keyValueData = pair.Split(new char[] { '=' }, 2); - if (keyValueData.Length != 2) - { - throw new ArgumentException("Headers provided in an invalid format."); - } - - var key = keyValueData[0].Trim(); - var value = keyValueData[1].Trim(); - metadata.Add(key, value); - }); - } - - return metadata; + return options.GetHeaders((m, k, v) => m.Add(k, v)); } } } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpHttpTraceExporter.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpHttpTraceExporter.cs new file mode 100644 index 00000000000..7700df9f2b3 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpHttpTraceExporter.cs @@ -0,0 +1,101 @@ +// +// 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. +// + +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using Google.Protobuf; +using OpenTelemetry.Exporter.OpenTelemetryProtocol; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; +using OtlpCollector = Opentelemetry.Proto.Collector.Trace.V1; + +namespace OpenTelemetry.Exporter +{ + /// + /// Exporter consuming and exporting the data using + /// the OpenTelemetry protocol (OTLP) over HTTP. + /// + public class OtlpHttpTraceExporter : BaseOtlpHttpExporter + { + internal const string MediaContentType = "application/x-protobuf"; + + internal OtlpHttpTraceExporter(OtlpExporterOptions options, IHttpHandler httpHandler = null) + : base(options, httpHandler) + { + } + + /// + public override ExportResult Export(in Batch activityBatch) + { + // Prevents the exporter's gRPC and HTTP operations from being instrumented. + using var scope = SuppressInstrumentationScope.Begin(); + + var request = new OtlpCollector.ExportTraceServiceRequest(); + request.AddBatch(this.ProcessResource, activityBatch); + + try + { + var httpRequest = this.CreateHttpRequest(request); + + // TODO: replace by synchronous vesrion of Send method when it becomes availabe. + // See https://github.com/dotnet/runtime/pull/34948 (should be available starting form .NET 5.0). + var response = this.HttpHandler.SendAsync(httpRequest).Result; + } + catch (HttpRequestException ex) + { + OpenTelemetryProtocolExporterEventSource.Log.ExportMethodException(ex); + + return ExportResult.Failure; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.ExportMethodException(ex); + + return ExportResult.Failure; + } + finally + { + request.Return(); + } + + return ExportResult.Success; + } + + private HttpRequestMessage CreateHttpRequest(OtlpCollector.ExportTraceServiceRequest request) + { + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, this.Options.Endpoint); + foreach (var header in this.Headers) + { + httpRequestMessage.Headers.Add(header.Key, header.Value); + } + + var content = Array.Empty(); + using (var stream = new MemoryStream()) + { + request.WriteTo(stream); + content = stream.ToArray(); + } + + var binaryContent = new ByteArrayContent(content); + binaryContent.Headers.ContentType = new MediaTypeHeaderValue(MediaContentType); + httpRequestMessage.Content = binaryContent; + + return httpRequestMessage; + } + } +} diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpHttpTraceExporterHelperExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpHttpTraceExporterHelperExtensions.cs new file mode 100644 index 00000000000..09b03666714 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpHttpTraceExporterHelperExtensions.cs @@ -0,0 +1,71 @@ +// +// 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. +// + +using System; +using OpenTelemetry.Exporter; + +namespace OpenTelemetry.Trace +{ + /// + /// Extension methods to simplify registering of the OpenTelemetry Protocol (OTLP) over HTTP exporter. + /// + public static class OtlpHttpTraceExporterHelperExtensions + { + /// + /// Adds OpenTelemetry Protocol (OTLP) over HTTP exporter to the TracerProvider. + /// + /// builder to use. + /// Exporter configuration options. + /// The instance of to chain the calls. + public static TracerProviderBuilder AddOtlpHttpExporter(this TracerProviderBuilder builder, Action configure = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (builder is IDeferredTracerProviderBuilder deferredTracerProviderBuilder) + { + return deferredTracerProviderBuilder.Configure((sp, builder) => + { + AddOtlpHttpExporter(builder, sp.GetOptions(), configure); + }); + } + + return AddOtlpHttpExporter(builder, new OtlpExporterOptions(), configure); + } + + private static TracerProviderBuilder AddOtlpHttpExporter(TracerProviderBuilder builder, OtlpExporterOptions exporterOptions, Action configure = null) + { + configure?.Invoke(exporterOptions); + var otlpHttpExporter = new OtlpHttpTraceExporter(exporterOptions); + + if (exporterOptions.ExportProcessorType == ExportProcessorType.Simple) + { + return builder.AddProcessor(new SimpleActivityExportProcessor(otlpHttpExporter)); + } + else + { + return builder.AddProcessor(new BatchActivityExportProcessor( + otlpHttpExporter, + exporterOptions.BatchExportProcessorOptions.MaxQueueSize, + exporterOptions.BatchExportProcessorOptions.ScheduledDelayMilliseconds, + exporterOptions.BatchExportProcessorOptions.ExporterTimeoutMilliseconds, + exporterOptions.BatchExportProcessorOptions.MaxExportBatchSize)); + } + } + } +} diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsGrpcExtensionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsGrpcExtensionsTests.cs index c75384cb0aa..f4bdf00a925 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsGrpcExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsGrpcExtensionsTests.cs @@ -46,7 +46,7 @@ public void GetMetadataFromHeadersWorksCorrectFormat(string headers, string[] ke [Theory] [InlineData("headers")] [InlineData("key,value")] - public void GetMetadataFromHeadersThrowsExceptionOnOnvalidFormat(string headers) + public void GetMetadataFromHeadersThrowsExceptionOnInvalidFormat(string headers) { try { diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpTraceExporterTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpTraceExporterTests.cs new file mode 100644 index 00000000000..12ac99f5b9d --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpTraceExporterTests.cs @@ -0,0 +1,159 @@ +// +// 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. +// + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; +using OpenTelemetry.Trace; +using Xunit; +using OtlpCollector = Opentelemetry.Proto.Collector.Trace.V1; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests +{ + public class OtlpHttpTraceExporterTests + { + static OtlpHttpTraceExporterTests() + { + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = true; + + var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + }; + + ActivitySource.AddActivityListener(listener); + } + + [Fact] + public void OtlpExporter_BadArgs() + { + TracerProviderBuilder builder = null; + Assert.Throws(() => builder.AddOtlpHttpExporter()); + } + + [Fact] + public void NewOtlpHttpTraceExporter_ExportHasDefaulProperties() + { + var exporter = new OtlpHttpTraceExporter(new OtlpExporterOptions()); + + Assert.NotNull(exporter.HttpHandler); + Assert.IsType(exporter.HttpHandler); + } + + [Fact] + public void NewOtlpHttpTraceExporter_ExporterHasDefinedProperties() + { + var header1 = new { Name = "hdr1", Value = "val1" }; + var header2 = new { Name = "hdr2", Value = "val2" }; + + var options = new OtlpExporterOptions + { + Headers = $"{header1.Name}={header1.Value}, {header2.Name} = {header2.Value}", + }; + + var exporter = new OtlpHttpTraceExporter(options, new NoopHttpHandler()); + + Assert.NotNull(exporter.HttpHandler); + Assert.IsType(exporter.HttpHandler); + + Assert.Equal(2, exporter.Headers.Count); + Assert.Contains(exporter.Headers, kvp => kvp.Key == header1.Name && kvp.Value == header1.Value); + Assert.Contains(exporter.Headers, kvp => kvp.Key == header2.Name && kvp.Value == header2.Value); + } + + [Fact] + public async Task Export_ActivityBatch_SendsCorrectHttpRequest() + { + using var activitySource = new ActivitySource(nameof(this.Export_ActivityBatch_SendsCorrectHttpRequest)); + + using var activity = activitySource.StartActivity($"activity-{nameof(this.Export_ActivityBatch_SendsCorrectHttpRequest)}", ActivityKind.Producer); + + var header1 = new { Name = "hdr1", Value = "val1" }; + var header2 = new { Name = "hdr2", Value = "val2" }; + + var options = new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4318"), + Headers = $"{header1.Name}={header1.Value}, {header2.Name} = {header2.Value}", + }; + + var httpHandlerMock = new Mock(); + HttpRequestMessage httpRequest = null; + httpHandlerMock.Setup(h => h.SendAsync(It.IsAny(), It.IsAny())) + .Callback((r, ct) => httpRequest = r); + + var exporter = new OtlpHttpTraceExporter(options, httpHandlerMock.Object); + + var result = exporter.Export(new Batch(activity)); + + httpHandlerMock.Verify(m => m.SendAsync(It.IsAny(), It.IsAny()), Times.Once()); + + Assert.Equal(ExportResult.Success, result); + Assert.NotNull(httpRequest); + Assert.Equal(HttpMethod.Post, httpRequest.Method); + Assert.Equal(options.Endpoint.AbsoluteUri, httpRequest.RequestUri.AbsoluteUri); + Assert.Equal(2, httpRequest.Headers.Count()); + Assert.Contains(httpRequest.Headers, h => h.Key == header1.Name && h.Value.First() == header1.Value); + Assert.Contains(httpRequest.Headers, h => h.Key == header2.Name && h.Value.First() == header2.Value); + + Assert.NotNull(httpRequest.Content); + Assert.IsType(httpRequest.Content); + Assert.Contains(httpRequest.Content.Headers, h => h.Key == "Content-Type" && h.Value.First() == OtlpHttpTraceExporter.MediaContentType); + + var exportTraceRequest = OtlpCollector.ExportTraceServiceRequest.Parser.ParseFrom(await httpRequest.Content.ReadAsByteArrayAsync()); + Assert.NotNull(exportTraceRequest); + + Assert.Single(exportTraceRequest.ResourceSpans); + Assert.Single(exportTraceRequest.ResourceSpans.First().Resource.Attributes); + } + + [Fact] + public void Shutdown_PendingHttpRequestsCancelled() + { + var httpHandlerMock = new Mock(); + + var exporter = new OtlpHttpTraceExporter(new OtlpExporterOptions(), httpHandlerMock.Object); + + var result = exporter.Shutdown(); + + httpHandlerMock.Verify(m => m.CancelPendingRequests(), Times.Once()); + } + + private class NoopHttpHandler : IHttpHandler + { + public void CancelPendingRequests() + { + } + + public void Dispose() + { + } + + public Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + { + return null; + } + } + } +}