diff --git a/src/OpenTelemetry.Exporter.Zipkin/Implementation/ZipkinActivityConversionExtensions.cs b/src/OpenTelemetry.Exporter.Zipkin/Implementation/ZipkinActivityConversionExtensions.cs
new file mode 100644
index 00000000000..806a26bcfc0
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.Zipkin/Implementation/ZipkinActivityConversionExtensions.cs
@@ -0,0 +1,229 @@
+//
+// 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.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using OpenTelemetry.Internal;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace OpenTelemetry.Exporter.Zipkin.Implementation
+{
+ internal static class ZipkinActivityConversionExtensions
+ {
+ private const long TicksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000;
+
+ private static readonly Dictionary RemoteEndpointServiceNameKeyResolutionDictionary = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [SpanAttributeConstants.PeerServiceKey] = 0, // RemoteEndpoint.ServiceName primary.
+ ["net.peer.name"] = 1, // RemoteEndpoint.ServiceName first alternative.
+ ["peer.hostname"] = 2, // RemoteEndpoint.ServiceName second alternative.
+ ["peer.address"] = 2, // RemoteEndpoint.ServiceName second alternative.
+ ["http.host"] = 3, // RemoteEndpoint.ServiceName for Http.
+ ["db.instance"] = 4, // RemoteEndpoint.ServiceName for Redis.
+ };
+
+ private static readonly string InvalidSpanId = default(ActivitySpanId).ToHexString();
+
+ private static readonly ConcurrentDictionary LocalEndpointCache = new ConcurrentDictionary();
+ private static readonly ConcurrentDictionary RemoteEndpointCache = new ConcurrentDictionary();
+
+ private static readonly DictionaryEnumerator.ForEachDelegate ProcessTagsRef = ProcessTags;
+ private static readonly ListEnumerator>.ForEachDelegate ProcessActivityEventsRef = ProcessActivityEvents;
+
+ internal static ZipkinSpan ToZipkinSpan(this Activity activity, ZipkinEndpoint defaultLocalEndpoint, bool useShortTraceIds = false)
+ {
+ var context = activity.Context;
+ var startTimestamp = activity.StartTimeUtc.ToEpochMicroseconds();
+
+ string parentId = EncodeSpanId(activity.ParentSpanId);
+ if (string.Equals(parentId, InvalidSpanId, StringComparison.Ordinal))
+ {
+ parentId = null;
+ }
+
+ var attributeEnumerationState = new AttributeEnumerationState
+ {
+ Tags = PooledList>.Create(),
+ };
+
+ DictionaryEnumerator.AllocationFreeForEach(activity.Tags, ref attributeEnumerationState, ProcessTagsRef);
+
+ var activitySource = activity.Source;
+ if (!string.IsNullOrEmpty(activitySource.Name))
+ {
+ PooledList>.Add(ref attributeEnumerationState.Tags, new KeyValuePair("library.name", activitySource.Name));
+ if (!string.IsNullOrEmpty(activitySource.Version))
+ {
+ PooledList>.Add(ref attributeEnumerationState.Tags, new KeyValuePair("library.version", activitySource.Version));
+ }
+ }
+
+ var localEndpoint = defaultLocalEndpoint;
+
+ var serviceName = attributeEnumerationState.ServiceName;
+
+ // override default service name
+ if (!string.IsNullOrWhiteSpace(serviceName))
+ {
+ if (!string.IsNullOrWhiteSpace(attributeEnumerationState.ServiceNamespace))
+ {
+ serviceName = attributeEnumerationState.ServiceNamespace + "." + serviceName;
+ }
+
+ if (!LocalEndpointCache.TryGetValue(serviceName, out localEndpoint))
+ {
+ localEndpoint = defaultLocalEndpoint.Clone(serviceName);
+ LocalEndpointCache.TryAdd(serviceName, localEndpoint);
+ }
+ }
+
+ ZipkinEndpoint remoteEndpoint = null;
+ if ((activity.Kind == ActivityKind.Client || activity.Kind == ActivityKind.Producer) && attributeEnumerationState.RemoteEndpointServiceName != null)
+ {
+ remoteEndpoint = RemoteEndpointCache.GetOrAdd(attributeEnumerationState.RemoteEndpointServiceName, ZipkinEndpoint.Create);
+ }
+
+ var annotations = PooledList.Create();
+ ListEnumerator>.AllocationFreeForEach(activity.Events, ref annotations, ProcessActivityEventsRef);
+
+ return new ZipkinSpan(
+ EncodeTraceId(context.TraceId, useShortTraceIds),
+ parentId,
+ EncodeSpanId(context.SpanId),
+ ToActivityKind(activity),
+ activity.OperationName,
+ activity.StartTimeUtc.ToEpochMicroseconds(),
+ duration: (long)activity.Duration.ToEpochMicroseconds(),
+ localEndpoint,
+ remoteEndpoint,
+ annotations,
+ attributeEnumerationState.Tags,
+ null,
+ null);
+ }
+
+ internal static string EncodeSpanId(ActivitySpanId spanId)
+ {
+ return spanId.ToHexString();
+ }
+
+ internal static long ToEpochMicroseconds(this DateTimeOffset dateTimeOffset)
+ {
+ return dateTimeOffset.Ticks / TicksPerMicrosecond;
+ }
+
+ internal static long ToEpochMicroseconds(this TimeSpan timeSpan)
+ {
+ return timeSpan.Ticks / TicksPerMicrosecond;
+ }
+
+ internal static long ToEpochMicroseconds(this DateTime utcDateTime)
+ {
+ const long UnixEpochTicks = 621355968000000000L; // = DateTimeOffset.FromUnixTimeMilliseconds(0).Ticks
+ const long UnixEpochMicroseconds = UnixEpochTicks / TicksPerMicrosecond;
+
+ // Truncate sub-microsecond precision before offsetting by the Unix Epoch to avoid
+ // the last digit being off by one for dates that result in negative Unix times
+ long microseconds = utcDateTime.Ticks / TicksPerMicrosecond;
+ return microseconds - UnixEpochMicroseconds;
+ }
+
+ private static string EncodeTraceId(ActivityTraceId traceId, bool useShortTraceIds)
+ {
+ var id = traceId.ToHexString();
+
+ if (id.Length > 16 && useShortTraceIds)
+ {
+ id = id.Substring(id.Length - 16, 16);
+ }
+
+ return id;
+ }
+
+ private static string ToActivityKind(Activity activity)
+ {
+ switch (activity.Kind)
+ {
+ case ActivityKind.Server:
+ return "SERVER";
+ case ActivityKind.Producer:
+ return "PRODUCER";
+ case ActivityKind.Consumer:
+ return "CONSUMER";
+ case ActivityKind.Client:
+ return "CLIENT";
+ }
+
+ return null;
+ }
+
+ private static bool ProcessActivityEvents(ref PooledList annotations, ActivityEvent @event)
+ {
+ PooledList.Add(ref annotations, new ZipkinAnnotation(@event.Timestamp.ToEpochMicroseconds(), @event.Name));
+ return true;
+ }
+
+ private static bool ProcessTags(ref AttributeEnumerationState state, KeyValuePair attribute)
+ {
+ string key = attribute.Key;
+ string strVal = attribute.Value;
+
+ if (strVal != null)
+ {
+ if (RemoteEndpointServiceNameKeyResolutionDictionary.TryGetValue(key, out int priority)
+ && (state.RemoteEndpointServiceName == null || priority < state.RemoteEndpointServiceNamePriority))
+ {
+ state.RemoteEndpointServiceName = strVal;
+ state.RemoteEndpointServiceNamePriority = priority;
+ }
+ else if (key == Resource.ServiceNameKey)
+ {
+ state.ServiceName = strVal;
+ }
+ else if (key == Resource.ServiceNamespaceKey)
+ {
+ state.ServiceNamespace = strVal;
+ }
+ else
+ {
+ PooledList>.Add(ref state.Tags, new KeyValuePair(key, strVal));
+ }
+ }
+ else
+ {
+ PooledList>.Add(ref state.Tags, new KeyValuePair(key, strVal));
+ }
+
+ return true;
+ }
+
+ private struct AttributeEnumerationState
+ {
+ public PooledList> Tags;
+
+ public string RemoteEndpointServiceName;
+
+ public int RemoteEndpointServiceNamePriority;
+
+ public string ServiceName;
+
+ public string ServiceNamespace;
+ }
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.Zipkin/Implementation/ZipkinSpan.cs b/src/OpenTelemetry.Exporter.Zipkin/Implementation/ZipkinSpan.cs
index 7a8bc1222e2..f00153c490c 100644
--- a/src/OpenTelemetry.Exporter.Zipkin/Implementation/ZipkinSpan.cs
+++ b/src/OpenTelemetry.Exporter.Zipkin/Implementation/ZipkinSpan.cs
@@ -112,7 +112,10 @@ public void Write(Utf8JsonWriter writer)
writer.WriteString("id", this.Id);
- writer.WriteString("kind", this.Kind);
+ if (this.Kind != null)
+ {
+ writer.WriteString("kind", this.Kind);
+ }
if (this.Timestamp.HasValue)
{
diff --git a/src/OpenTelemetry.Exporter.Zipkin/TracerBuilderExtensions.cs b/src/OpenTelemetry.Exporter.Zipkin/TracerBuilderExtensions.cs
index 4a32e0bdd9e..3551b9f2540 100644
--- a/src/OpenTelemetry.Exporter.Zipkin/TracerBuilderExtensions.cs
+++ b/src/OpenTelemetry.Exporter.Zipkin/TracerBuilderExtensions.cs
@@ -83,5 +83,33 @@ public static TracerBuilder UseZipkin(this TracerBuilder builder, Action
+ /// Registers a Zipkin exporter that will receive instances.
+ ///
+ /// builder to use.
+ /// Exporter configuration options.
+ /// The instance of to chain the calls.
+ public static OpenTelemetryBuilder UseZipkinActivityExporter(this OpenTelemetryBuilder builder, Action configure)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ if (configure == null)
+ {
+ throw new ArgumentNullException(nameof(configure));
+ }
+
+ return builder.AddProcessorPipeline(pipeline =>
+ {
+ var options = new ZipkinTraceExporterOptions();
+ configure(options);
+
+ var activityExporter = new ZipkinActivityExporter(options);
+ pipeline.SetExporter(activityExporter);
+ });
+ }
}
}
diff --git a/src/OpenTelemetry.Exporter.Zipkin/ZipkinActivityExporter.cs b/src/OpenTelemetry.Exporter.Zipkin/ZipkinActivityExporter.cs
new file mode 100644
index 00000000000..8b0f8435021
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.Zipkin/ZipkinActivityExporter.cs
@@ -0,0 +1,220 @@
+//
+// 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.IO;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Net.Sockets;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenTelemetry.Exporter.Zipkin.Implementation;
+using OpenTelemetry.Trace.Export;
+
+namespace OpenTelemetry.Exporter.Zipkin
+{
+ ///
+ /// Zipkin exporter.
+ ///
+ public class ZipkinActivityExporter : ActivityExporter
+ {
+ private readonly ZipkinTraceExporterOptions options;
+ private readonly HttpClient httpClient;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Configuration options.
+ /// Http client to use to upload telemetry.
+ public ZipkinActivityExporter(ZipkinTraceExporterOptions options, HttpClient client = null)
+ {
+ this.options = options;
+ this.LocalEndpoint = this.GetLocalZipkinEndpoint();
+ this.httpClient = client ?? new HttpClient();
+ }
+
+ internal ZipkinEndpoint LocalEndpoint { get; }
+
+ ///
+ public override async Task ExportAsync(IEnumerable batchActivity, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await this.SendBatchActivityAsync(batchActivity).ConfigureAwait(false);
+ return ExportResult.Success;
+ }
+ catch (Exception)
+ {
+ // TODO distinguish retryable exceptions
+ return ExportResult.FailedNotRetryable;
+ }
+ }
+
+ ///
+ public override Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ private Task SendBatchActivityAsync(IEnumerable batchActivity)
+ {
+ var requestUri = this.options.Endpoint;
+
+ var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
+ {
+ Content = new JsonContent(this, batchActivity),
+ };
+
+ // avoid cancelling here: this is no return point: if we reached this point
+ // and cancellation is requested, it's better if we try to finish sending spans rather than drop it
+ return this.httpClient.SendAsync(request);
+ }
+
+ private ZipkinEndpoint GetLocalZipkinEndpoint()
+ {
+ var hostName = this.ResolveHostName();
+
+ string ipv4 = null;
+ string ipv6 = null;
+ if (!string.IsNullOrEmpty(hostName))
+ {
+ ipv4 = this.ResolveHostAddress(hostName, AddressFamily.InterNetwork);
+ ipv6 = this.ResolveHostAddress(hostName, AddressFamily.InterNetworkV6);
+ }
+
+ return new ZipkinEndpoint(
+ this.options.ServiceName,
+ ipv4,
+ ipv6,
+ null);
+ }
+
+ private string ResolveHostAddress(string hostName, AddressFamily family)
+ {
+ string result = null;
+
+ try
+ {
+ var results = Dns.GetHostAddresses(hostName);
+
+ if (results != null && results.Length > 0)
+ {
+ foreach (var addr in results)
+ {
+ if (addr.AddressFamily.Equals(family))
+ {
+ var sanitizedAddress = new IPAddress(addr.GetAddressBytes()); // Construct address sans ScopeID
+ result = sanitizedAddress.ToString();
+
+ break;
+ }
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Ignore
+ }
+
+ return result;
+ }
+
+ private string ResolveHostName()
+ {
+ string result = null;
+
+ try
+ {
+ result = Dns.GetHostName();
+
+ if (!string.IsNullOrEmpty(result))
+ {
+ var response = Dns.GetHostEntry(result);
+
+ if (response != null)
+ {
+ return response.HostName;
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Ignore
+ }
+
+ return result;
+ }
+
+ private class JsonContent : HttpContent
+ {
+ private static readonly MediaTypeHeaderValue JsonHeader = new MediaTypeHeaderValue("application/json")
+ {
+ CharSet = "utf-8",
+ };
+
+ private readonly ZipkinActivityExporter exporter;
+ private readonly IEnumerable batchActivity;
+
+ private Utf8JsonWriter writer;
+
+ public JsonContent(ZipkinActivityExporter exporter, IEnumerable batchActivity)
+ {
+ this.exporter = exporter;
+ this.batchActivity = batchActivity;
+
+ this.Headers.ContentType = JsonHeader;
+ }
+
+ protected override Task SerializeToStreamAsync(Stream stream, TransportContext context)
+ {
+ if (this.writer == null)
+ {
+ this.writer = new Utf8JsonWriter(stream);
+ }
+ else
+ {
+ this.writer.Reset(stream);
+ }
+
+ this.writer.WriteStartArray();
+
+ foreach (var activity in this.batchActivity)
+ {
+ var zipkinSpan = activity.ToZipkinSpan(this.exporter.LocalEndpoint, this.exporter.options.UseShortTraceIds);
+
+ zipkinSpan.Write(this.writer);
+
+ zipkinSpan.Return();
+ }
+
+ this.writer.WriteEndArray();
+
+ return this.writer.FlushAsync();
+ }
+
+ protected override bool TryComputeLength(out long length)
+ {
+ // We can't know the length of the content being pushed to the output stream.
+ length = -1;
+ return false;
+ }
+ }
+ }
+}
diff --git a/test/OpenTelemetry.Exporter.Zipkin.Tests/Implementation/ZipkinActivityConversionTest.cs b/test/OpenTelemetry.Exporter.Zipkin.Tests/Implementation/ZipkinActivityConversionTest.cs
new file mode 100644
index 00000000000..27a6d086b47
--- /dev/null
+++ b/test/OpenTelemetry.Exporter.Zipkin.Tests/Implementation/ZipkinActivityConversionTest.cs
@@ -0,0 +1,89 @@
+//
+// 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.Diagnostics;
+using System.Linq;
+using OpenTelemetry.Exporter.Zipkin.Implementation;
+using Xunit;
+
+namespace OpenTelemetry.Exporter.Zipkin.Tests.Implementation
+{
+ public class ZipkinActivityConversionTest
+ {
+ private const string ZipkinSpanName = "Name";
+ private static readonly ZipkinEndpoint DefaultZipkinEndpoint = new ZipkinEndpoint("TestService");
+
+ [Fact]
+ public void ZipkinActivityConversion_ToZipkinSpan_AllPropertiesSet()
+ {
+ // Arrange
+ var activity = ZipkinActivityExporterTests.CreateTestActivity();
+
+ // Act & Assert
+ var zipkinSpan = activity.ToZipkinSpan(DefaultZipkinEndpoint);
+
+ Assert.Equal(ZipkinSpanName, zipkinSpan.Name);
+
+ Assert.Equal(activity.TraceId.ToHexString(), zipkinSpan.TraceId);
+ Assert.Equal(activity.SpanId.ToHexString(), zipkinSpan.Id);
+
+ Assert.Equal(activity.StartTimeUtc.ToEpochMicroseconds(), zipkinSpan.Timestamp);
+ Assert.Equal((long)(activity.Duration.TotalMilliseconds * 1000), zipkinSpan.Duration);
+
+ int counter = 0;
+ var tagsArray = zipkinSpan.Tags.Value.ToArray();
+
+ foreach (var tags in activity.Tags)
+ {
+ Assert.Equal(tagsArray[counter].Key, tags.Key);
+ Assert.Equal(tagsArray[counter++].Value, tags.Value);
+ }
+
+ foreach (var annotation in zipkinSpan.Annotations)
+ {
+ // Timestamp is same in both events
+ Assert.Equal(activity.Events.First().Timestamp.ToEpochMicroseconds(), annotation.Timestamp);
+ }
+ }
+
+ [Fact]
+ public void ZipkinActivityConversion_ToZipkinSpan_NoEvents()
+ {
+ // Arrange
+ var activity = ZipkinActivityExporterTests.CreateTestActivity(addEvents: false);
+
+ // Act & Assert
+ var zipkinSpan = activity.ToZipkinSpan(DefaultZipkinEndpoint);
+
+ Assert.Equal(ZipkinSpanName, zipkinSpan.Name);
+ Assert.Empty(zipkinSpan.Annotations.Value);
+ Assert.Equal(activity.TraceId.ToHexString(), zipkinSpan.TraceId);
+ Assert.Equal(activity.SpanId.ToHexString(), zipkinSpan.Id);
+
+ int counter = 0;
+ var tagsArray = zipkinSpan.Tags.Value.ToArray();
+
+ foreach (var tags in activity.Tags)
+ {
+ Assert.Equal(tagsArray[counter].Key, tags.Key);
+ Assert.Equal(tagsArray[counter++].Value, tags.Value);
+ }
+
+ Assert.Equal(activity.StartTimeUtc.ToEpochMicroseconds(), zipkinSpan.Timestamp);
+ Assert.Equal((long)activity.Duration.TotalMilliseconds * 1000, zipkinSpan.Duration);
+ }
+ }
+}
diff --git a/test/OpenTelemetry.Exporter.Zipkin.Tests/Implementation/ZipkinActivityExporterRemoteEndpointTests.cs b/test/OpenTelemetry.Exporter.Zipkin.Tests/Implementation/ZipkinActivityExporterRemoteEndpointTests.cs
new file mode 100644
index 00000000000..8d48e18ccf8
--- /dev/null
+++ b/test/OpenTelemetry.Exporter.Zipkin.Tests/Implementation/ZipkinActivityExporterRemoteEndpointTests.cs
@@ -0,0 +1,75 @@
+//
+// 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.Collections.Generic;
+using OpenTelemetry.Exporter.Zipkin.Implementation;
+using Xunit;
+
+namespace OpenTelemetry.Exporter.Zipkin.Tests.Implementation
+{
+ public class ZipkinActivityExporterRemoteEndpointTests
+ {
+ private static readonly ZipkinEndpoint DefaultZipkinEndpoint = new ZipkinEndpoint("TestService");
+
+ [Fact]
+ public void ZipkinSpanConverterTest_GenerateActivity_RemoteEndpointOmittedByDefault()
+ {
+ // Arrange
+ var activity = ZipkinActivityExporterTests.CreateTestActivity();
+
+ // Act & Assert
+ var zipkinSpan = ZipkinActivityConversionExtensions.ToZipkinSpan(activity, DefaultZipkinEndpoint);
+
+ Assert.Null(zipkinSpan.RemoteEndpoint);
+ }
+
+ [Fact]
+ public void ZipkinSpanConverterTest_GenerateActivity_RemoteEndpointResolution()
+ {
+ // Arrange
+ var activity = ZipkinActivityExporterTests.CreateTestActivity(
+ additionalAttributes: new Dictionary
+ {
+ ["net.peer.name"] = "RemoteServiceName",
+ });
+
+ // Act & Assert
+ var zipkinSpan = ZipkinActivityConversionExtensions.ToZipkinSpan(activity, DefaultZipkinEndpoint);
+
+ Assert.NotNull(zipkinSpan.RemoteEndpoint);
+ Assert.Equal("RemoteServiceName", zipkinSpan.RemoteEndpoint.ServiceName);
+ }
+
+ [Fact]
+ public void ZipkinSpanConverterTest_GenerateActivity_RemoteEndpointResolutionPriority()
+ {
+ // Arrange
+ var activity = ZipkinActivityExporterTests.CreateTestActivity(
+ additionalAttributes: new Dictionary
+ {
+ ["http.host"] = "DiscardedRemoteServiceName",
+ ["net.peer.name"] = "RemoteServiceName",
+ ["peer.hostname"] = "DiscardedRemoteServiceName",
+ });
+
+ // Act & Assert
+ var zipkinSpan = ZipkinActivityConversionExtensions.ToZipkinSpan(activity, DefaultZipkinEndpoint);
+
+ Assert.NotNull(zipkinSpan.RemoteEndpoint);
+ Assert.Equal("RemoteServiceName", zipkinSpan.RemoteEndpoint.ServiceName);
+ }
+ }
+}
diff --git a/test/OpenTelemetry.Exporter.Zipkin.Tests/ZipkinActivityExporterTests.cs b/test/OpenTelemetry.Exporter.Zipkin.Tests/ZipkinActivityExporterTests.cs
new file mode 100644
index 00000000000..462bf328a6c
--- /dev/null
+++ b/test/OpenTelemetry.Exporter.Zipkin.Tests/ZipkinActivityExporterTests.cs
@@ -0,0 +1,219 @@
+//
+// 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.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenTelemetry.Exporter.Zipkin.Implementation;
+using OpenTelemetry.Internal.Test;
+using OpenTelemetry.Resources;
+using Xunit;
+
+namespace OpenTelemetry.Exporter.Zipkin.Tests
+{
+ public class ZipkinActivityExporterTests : IDisposable
+ {
+ private const string TraceId = "e8ea7e9ac72de94e91fabc613f9686b2";
+ private static readonly ConcurrentDictionary Responses = new ConcurrentDictionary();
+
+ private readonly IDisposable testServer;
+ private readonly string testServerHost;
+ private readonly int testServerPort;
+
+ static ZipkinActivityExporterTests()
+ {
+ Activity.DefaultIdFormat = ActivityIdFormat.W3C;
+ Activity.ForceDefaultIdFormat = true;
+
+ var listener = new ActivityListener
+ {
+ ShouldListenTo = _ => true,
+ GetRequestedDataUsingParentId = (ref ActivityCreationOptions options) => ActivityDataRequest.AllData,
+ GetRequestedDataUsingContext = (ref ActivityCreationOptions options) => ActivityDataRequest.AllData,
+ };
+
+ ActivitySource.AddActivityListener(listener);
+ }
+
+ public ZipkinActivityExporterTests()
+ {
+ this.testServer = TestHttpServer.RunServer(
+ ctx => ProcessServerRequest(ctx),
+ out this.testServerHost,
+ out this.testServerPort);
+
+ static void ProcessServerRequest(HttpListenerContext context)
+ {
+ context.Response.StatusCode = 200;
+
+ using StreamReader readStream = new StreamReader(context.Request.InputStream);
+
+ string requestContent = readStream.ReadToEnd();
+
+ Responses.TryAdd(
+ Guid.Parse(context.Request.QueryString["requestId"]),
+ requestContent);
+
+ context.Response.OutputStream.Close();
+ }
+ }
+
+ public void Dispose()
+ {
+ this.testServer.Dispose();
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task ZipkinActivityExporterIntegrationTest(bool useShortTraceIds)
+ {
+ var batchActivity = new List { CreateTestActivity() };
+
+ Guid requestId = Guid.NewGuid();
+
+ ZipkinActivityExporter exporter = new ZipkinActivityExporter(
+ new ZipkinTraceExporterOptions
+ {
+ Endpoint = new Uri($"http://{this.testServerHost}:{this.testServerPort}/api/v2/spans?requestId={requestId}"),
+ UseShortTraceIds = useShortTraceIds,
+ });
+
+ await exporter.ExportAsync(batchActivity, CancellationToken.None).ConfigureAwait(false);
+
+ await exporter.ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
+
+ var activity = batchActivity[0];
+ var context = activity.Context;
+
+ var timestamp = activity.StartTimeUtc.ToEpochMicroseconds();
+ var eventTimestamp = activity.Events.First().Timestamp.ToEpochMicroseconds();
+
+ StringBuilder ipInformation = new StringBuilder();
+ if (!string.IsNullOrEmpty(exporter.LocalEndpoint.Ipv4))
+ {
+ ipInformation.Append($@",""ipv4"":""{exporter.LocalEndpoint.Ipv4}""");
+ }
+
+ if (!string.IsNullOrEmpty(exporter.LocalEndpoint.Ipv6))
+ {
+ ipInformation.Append($@",""ipv6"":""{exporter.LocalEndpoint.Ipv6}""");
+ }
+
+ var traceId = useShortTraceIds ? TraceId.Substring(TraceId.Length - 16, 16) : TraceId;
+
+ Assert.Equal(
+ $@"[{{""traceId"":""{traceId}"",""name"":""Name"",""parentId"":""{ZipkinConversionExtensions.EncodeSpanId(activity.ParentSpanId)}"",""id"":""{ZipkinActivityConversionExtensions.EncodeSpanId(context.SpanId)}"",""kind"":""CLIENT"",""timestamp"":{timestamp},""duration"":60000000,""localEndpoint"":{{""serviceName"":""Open Telemetry Exporter""{ipInformation}}},""annotations"":[{{""timestamp"":{eventTimestamp},""value"":""Event1""}},{{""timestamp"":{eventTimestamp},""value"":""Event2""}}],""tags"":{{""stringKey"":""value"",""longKey"":""1"",""longKey2"":""1"",""doubleKey"":""1"",""doubleKey2"":""1"",""boolKey"":""True"",""library.name"":""CreateTestActivity""}}}}]",
+ Responses[requestId]);
+ }
+
+ internal static Activity CreateTestActivity(
+ bool setAttributes = true,
+ Dictionary additionalAttributes = null,
+ bool addEvents = true,
+ bool addLinks = true,
+ Resource resource = null,
+ ActivityKind kind = ActivityKind.Client)
+ {
+ var startTimestamp = DateTime.UtcNow;
+ var endTimestamp = startTimestamp.AddSeconds(60);
+ var eventTimestamp = DateTime.UtcNow;
+ var traceId = ActivityTraceId.CreateFromString("e8ea7e9ac72de94e91fabc613f9686b2".AsSpan());
+
+ var parentSpanId = ActivitySpanId.CreateFromBytes(new byte[] { 12, 23, 34, 45, 56, 67, 78, 89 });
+
+ var attributes = new Dictionary
+ {
+ { "stringKey", "value" },
+ { "longKey", 1L },
+ { "longKey2", 1 },
+ { "doubleKey", 1D },
+ { "doubleKey2", 1F },
+ { "boolKey", true },
+ };
+ if (additionalAttributes != null)
+ {
+ foreach (var attribute in additionalAttributes)
+ {
+ attributes.Add(attribute.Key, attribute.Value);
+ }
+ }
+
+ var events = new List
+ {
+ new ActivityEvent(
+ "Event1",
+ eventTimestamp,
+ new Dictionary
+ {
+ { "key", "value" },
+ }),
+ new ActivityEvent(
+ "Event2",
+ eventTimestamp,
+ new Dictionary
+ {
+ { "key", "value" },
+ }),
+ };
+
+ var linkedSpanId = ActivitySpanId.CreateFromString("888915b6286b9c41".AsSpan());
+
+ var activitySource = new ActivitySource(nameof(CreateTestActivity));
+
+ var tags = setAttributes ?
+ attributes.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.ToString()))
+ : null;
+ var links = addLinks ?
+ new[]
+ {
+ new ActivityLink(new ActivityContext(
+ traceId,
+ linkedSpanId,
+ ActivityTraceFlags.Recorded)),
+ }
+ : null;
+
+ var activity = activitySource.StartActivity(
+ "Name",
+ kind,
+ parentContext: new ActivityContext(traceId, parentSpanId, ActivityTraceFlags.Recorded),
+ tags,
+ links,
+ startTime: startTimestamp);
+
+ if (addEvents)
+ {
+ foreach (var evnt in events)
+ {
+ activity.AddEvent(evnt);
+ }
+ }
+
+ activity.SetEndTime(endTimestamp);
+ activity.Stop();
+
+ return activity;
+ }
+ }
+}