diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e108b709d2..cd1df13e26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,5 @@ - Initial Java API and SDK for context, trace, metrics, resource. - Initial implementation of the Jaeger exporter. +- Initial implementation of the Zipkin exporter. - Initial implementation of the OTLP exporters for trace and metrics. \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md index 8eb1d528ff0..d915b934605 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -352,6 +352,7 @@ Span processors are initialized with an exporter which is responsible for sendin a particular backend. OpenTelemetry offers four exporters out of the box: - In-Memory Exporter: keeps the data in memory, useful for debugging. - Jaeger Exporter: prepares and sends the collected telemetry data to a Jaeger backend via gRPC. +- Zipkin Exporter: prepares and sends the collected telemetry data to a Zipkin backend via the Zipkin APIs. - Logging Exporter: saves the telemetry data into log streams. - OpenTelemetry Exporter: sends the data to the [OpenTelemetry Collector] (not yet implemented). diff --git a/README.md b/README.md index 0f086a1c16e..434ea23596b 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ This is a **current** feature status list: | Metrics SDK | v0.3.0 | | OTLP Exporter | v0.3.0 | | Jaeger Trace Exporter | v0.3.0 | -| Zipkin Trace Exporter | N/A | +| Zipkin Trace Exporter | dev | | Prometheus Metrics Exporter | dev | | Context Propagation | v0.3.0 | | OpenTracing Bridge | v0.3.0 | diff --git a/all/build.gradle b/all/build.gradle index b9d98b800e4..147e39747e0 100644 --- a/all/build.gradle +++ b/all/build.gradle @@ -17,6 +17,7 @@ def subprojects = [ project(':opentelemetry-exporters-jaeger'), project(':opentelemetry-exporters-otlp'), project(':opentelemetry-exporters-prometheus'), + project(':opentelemetry-exporters-zipkin'), project(':opentelemetry-opentracing-shim'), project(':opentelemetry-sdk'), project(':opentelemetry-sdk-contrib-async-processor'), diff --git a/build.gradle b/build.gradle index b57cc9feffc..5be4c0ffd3f 100644 --- a/build.gradle +++ b/build.gradle @@ -96,6 +96,7 @@ subprojects { prometheusVersion = '0.8.1' protobufVersion = '3.11.4' protocVersion = '3.11.4' + zipkinReporterVersion = '2.12.2' libraries = [ auto_value : "com.google.auto.value:auto-value:${autoValueVersion}", @@ -117,6 +118,8 @@ subprojects { prometheus_client_common: "io.prometheus:simpleclient_common:${prometheusVersion}", protobuf : "com.google.protobuf:protobuf-java:${protobufVersion}", protobuf_util : "com.google.protobuf:protobuf-java-util:${protobufVersion}", + zipkin_reporter : "io.zipkin.reporter2:zipkin-reporter:${zipkinReporterVersion}", + zipkin_urlconnection : "io.zipkin.reporter2:zipkin-sender-urlconnection:${zipkinReporterVersion}", // Compatibility layer opentracing : "io.opentracing:opentracing-api:${opentracingVersion}", diff --git a/exporters/zipkin/README.md b/exporters/zipkin/README.md new file mode 100644 index 00000000000..947d9af7f0d --- /dev/null +++ b/exporters/zipkin/README.md @@ -0,0 +1,40 @@ +# OpenTelemetry - Zipkin Span Exporter + +[![Javadocs][javadoc-image]][javadoc-url] + +This is an OpenTelemetry exporter that sends span data using the [io.zipkin.reporter2:zipkin-reporter](https://github.com/openzipkin/zipkin-reporter-java") library. + +By default, this POSTs json in [Zipkin format](https://zipkin.io/zipkin-api/#/default/post_spans) to +a specified HTTP URL. This could be to a [Zipkin](https://zipkin.io) service, or anything that +consumes the same format. + +You can alternatively use other formats, such as protobuf, or override the `Sender` to use a non-HTTP transport, such as Kafka. + +## Configuration + +The Zipkin span exporter can be configured programmatically. + +An example of simple Zipkin exporter initialization. In this case +spans will be sent to a Zipkin endpoint running on `localhost`: + +```java + ZipkinExporterConfiguration configuration = + ZipkinExporterConfiguration.builder() + .setEndpoint("http://localhost/api/v2/spans") + .setServiceName("my-service") + .build(); + + ZipkinSpanExporter exporter = ZipkinSpanExporter.create(configuration); +``` + +## Compatibility + +As with the OpenTelemetry SDK itself, this exporter is compatible with Java 7+ and Android API level 24+. + +## Attribution + +The code in this module is based on the [OpenCensus Zipkin exporter][oc-origin] code. + +[javadoc-image]: https://www.javadoc.io/badge/io.opentelemetry/opentelemetry-exporters-zipkin.svg +[javadoc-url]: https://www.javadoc.io/doc/io.opentelemetry/opentelemetry-exporters-zipkin +[oc-origin]: https://github.com/census-instrumentation/opencensus-java/ diff --git a/exporters/zipkin/build.gradle b/exporters/zipkin/build.gradle new file mode 100644 index 00000000000..18cc1b2f5b3 --- /dev/null +++ b/exporters/zipkin/build.gradle @@ -0,0 +1,32 @@ +plugins { + id "java" + id "maven-publish" + + id "ru.vyarus.animalsniffer" +} + +description = 'OpenTelemetry - Zipkin Exporter' +ext.moduleName = "io.opentelemetry.exporters.zipkin" + +dependencies { + compileOnly libraries.auto_value + + api project(':opentelemetry-sdk') + + annotationProcessor libraries.auto_value + + implementation libraries.zipkin_reporter + implementation libraries.zipkin_urlconnection + + testImplementation libraries.guava + + signature "org.codehaus.mojo.signature:java17:1.0@signature" + signature "net.sf.androidscents.signature:android-api-level-24:7.0_r2@signature" +} + +animalsniffer { + // Don't check sourceSets.jmh and sourceSets.test + sourceSets = [ + sourceSets.main + ] +} diff --git a/exporters/zipkin/src/main/java/io/opentelemetry/exporters/zipkin/ZipkinExporterConfiguration.java b/exporters/zipkin/src/main/java/io/opentelemetry/exporters/zipkin/ZipkinExporterConfiguration.java new file mode 100644 index 00000000000..f6daf1a0d86 --- /dev/null +++ b/exporters/zipkin/src/main/java/io/opentelemetry/exporters/zipkin/ZipkinExporterConfiguration.java @@ -0,0 +1,126 @@ +/* + * Copyright 2020, 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. + */ + +package io.opentelemetry.exporters.zipkin; + +import com.google.auto.value.AutoValue; +import javax.annotation.concurrent.Immutable; +import zipkin2.Span; +import zipkin2.codec.BytesEncoder; +import zipkin2.codec.SpanBytesEncoder; +import zipkin2.reporter.Sender; +import zipkin2.reporter.urlconnection.URLConnectionSender; + +/** + * Configurations for {@link ZipkinSpanExporter}. + * + * @since 0.4.0 + */ +@AutoValue +@Immutable +public abstract class ZipkinExporterConfiguration { + + ZipkinExporterConfiguration() {} + + abstract String getServiceName(); + + abstract Sender getSender(); + + abstract BytesEncoder getEncoder(); + + /** + * Returns a new {@link Builder}, defaulted to use the {@link SpanBytesEncoder#JSON_V2} encoder. + * + * @return a {@code Builder}. + * @since 0.4.0 + */ + public static Builder builder() { + return new AutoValue_ZipkinExporterConfiguration.Builder().setEncoder(SpanBytesEncoder.JSON_V2); + } + + /** + * Builder for {@link ZipkinExporterConfiguration}. + * + * @since 0.4.0 + */ + @AutoValue.Builder + public abstract static class Builder { + + Builder() {} + + /** + * Label of the remote node in the service graph, such as "favstar". Avoid names with variables + * or unique identifiers embedded. Defaults to "unknown". + * + *

This is a primary label for trace lookup and aggregation, so it should be intuitive and + * consistent. Many use a name from service discovery. + * + *

Note: this value, will be superceded by the value of {@link + * io.opentelemetry.sdk.resources.ResourceConstants#SERVICE_NAME} if it has been set in the + * {@link io.opentelemetry.sdk.resources.Resource} associated with the Tracer that created the + * spans. + * + *

This property is required to be set. + * + * @see io.opentelemetry.sdk.resources.Resource + * @see io.opentelemetry.sdk.resources.ResourceConstants + * @since 0.4.0 + */ + public abstract Builder setServiceName(String serviceName); + + /** + * Sets the Zipkin sender. Implements the client side of the span transport. A {@link + * URLConnectionSender} is a good default. + * + *

The {@link Sender#close()} method will be called when the exporter is shut down. + * + * @param sender the Zipkin sender implementation. + * @since 0.4.0 + */ + public abstract Builder setSender(Sender sender); + + /** + * Sets the {@link BytesEncoder}, which controls the format used by the {@link Sender}. Defaults + * to the {@link SpanBytesEncoder#JSON_V2}. + * + * @param encoder the {@code BytesEncoder} to use. + * @see SpanBytesEncoder + * @since 0.4.0 + */ + public abstract Builder setEncoder(BytesEncoder encoder); + + /** + * Sets the zipkin endpoint. This will use the endpoint to assign a {@link URLConnectionSender} + * instance to this builder. + * + * @param endpoint The Zipkin endpoint URL, ex. "http://zipkinhost:9411/api/v2/spans". + * @see URLConnectionSender + * @since 0.4.0 + */ + public Builder setEndpoint(String endpoint) { + setSender(URLConnectionSender.create(endpoint)); + return this; + } + + /** + * Builds a {@link ZipkinExporterConfiguration}. + * + * @return a {@code ZipkinExporterConfiguration}. + * @since 0.4.0 + */ + public abstract ZipkinExporterConfiguration build(); + } +} diff --git a/exporters/zipkin/src/main/java/io/opentelemetry/exporters/zipkin/ZipkinSpanExporter.java b/exporters/zipkin/src/main/java/io/opentelemetry/exporters/zipkin/ZipkinSpanExporter.java new file mode 100644 index 00000000000..3ef5491381a --- /dev/null +++ b/exporters/zipkin/src/main/java/io/opentelemetry/exporters/zipkin/ZipkinSpanExporter.java @@ -0,0 +1,256 @@ +/* + * Copyright 2020, 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. + */ + +package io.opentelemetry.exporters.zipkin; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +import io.opentelemetry.common.AttributeValue; +import io.opentelemetry.common.AttributeValue.Type; +import io.opentelemetry.sdk.resources.ResourceConstants; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.opentelemetry.trace.Span.Kind; +import io.opentelemetry.trace.Status; +import io.opentelemetry.trace.attributes.SemanticAttributes; +import java.io.IOException; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import zipkin2.Endpoint; +import zipkin2.Span; +import zipkin2.codec.BytesEncoder; +import zipkin2.reporter.Sender; + +/** + * This class was based on the OpenCensus zipkin exporter code at + * https://github.com/census-instrumentation/opencensus-java/tree/c960b19889de5e4a7b25f90919d28b066590d4f0/exporters/trace/zipkin + */ +final class ZipkinSpanExporter implements SpanExporter { + + private static final Logger logger = Logger.getLogger(ZipkinSpanExporter.class.getName()); + + // The naming follows Zipkin convention. For http see here: + // https://github.com/openzipkin/brave/blob/eee993f998ae57b08644cc357a6d478827428710/instrumentation/http/src/main/java/brave/http/HttpTags.java + // For discussion about GRPC errors/tags, see here: https://github.com/openzipkin/brave/pull/999 + // Note: these 3 fields are non-private for testing + static final String GRPC_STATUS_CODE = "grpc.status_code"; + static final String GRPC_STATUS_DESCRIPTION = "grpc.status_description"; + static final String STATUS_ERROR = "error"; + + private final BytesEncoder encoder; + private final Sender sender; + private final Endpoint localEndpoint; + + ZipkinSpanExporter(BytesEncoder encoder, Sender sender, String serviceName) { + this.encoder = encoder; + this.sender = sender; + this.localEndpoint = produceLocalEndpoint(serviceName); + } + + /** Logic borrowed from brave.internal.Platform.produceLocalEndpoint */ + static Endpoint produceLocalEndpoint(String serviceName) { + Endpoint.Builder builder = Endpoint.newBuilder().serviceName(serviceName); + try { + Enumeration nics = NetworkInterface.getNetworkInterfaces(); + if (nics == null) { + return builder.build(); + } + while (nics.hasMoreElements()) { + NetworkInterface nic = nics.nextElement(); + Enumeration addresses = nic.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress address = addresses.nextElement(); + if (address.isSiteLocalAddress()) { + builder.ip(address); + break; + } + } + } + } catch (Exception e) { + // don't crash the caller if there was a problem reading nics. + if (logger.isLoggable(Level.FINE)) { + logger.log(Level.FINE, "error reading nics", e); + } + } + return builder.build(); + } + + static Span generateSpan(SpanData spanData, Endpoint localEndpoint) { + Endpoint endpoint = chooseEndpoint(spanData, localEndpoint); + + long startTimestamp = toEpochMicros(spanData.getStartEpochNanos()); + + long endTimestamp = toEpochMicros(spanData.getEndEpochNanos()); + + Span.Builder spanBuilder = + Span.newBuilder() + .traceId(spanData.getTraceId().toLowerBase16()) + .id(spanData.getSpanId().toLowerBase16()) + .kind(toSpanKind(spanData)) + .name(spanData.getName()) + .timestamp(toEpochMicros(spanData.getStartEpochNanos())) + .duration(endTimestamp - startTimestamp) + .localEndpoint(endpoint); + + if (spanData.getParentSpanId().isValid()) { + spanBuilder.parentId(spanData.getParentSpanId().toLowerBase16()); + } + + Map spanAttributes = spanData.getAttributes(); + for (Map.Entry label : spanAttributes.entrySet()) { + spanBuilder.putTag(label.getKey(), attributeValueToString(label.getValue())); + } + Status status = spanData.getStatus(); + // for GRPC spans, include status code & description. + if (status != null && spanAttributes.containsKey(SemanticAttributes.RPC_SERVICE.key())) { + spanBuilder.putTag(GRPC_STATUS_CODE, status.getCanonicalCode().toString()); + if (status.getDescription() != null) { + spanBuilder.putTag(GRPC_STATUS_DESCRIPTION, status.getDescription()); + } + } + // add the error tag, if it isn't already in the source span. + if (status != null && !status.isOk() && !spanAttributes.containsKey(STATUS_ERROR)) { + spanBuilder.putTag(STATUS_ERROR, status.getCanonicalCode().toString()); + } + + for (SpanData.TimedEvent annotation : spanData.getTimedEvents()) { + spanBuilder.addAnnotation(toEpochMicros(annotation.getEpochNanos()), annotation.getName()); + } + + return spanBuilder.build(); + } + + private static Endpoint chooseEndpoint(SpanData spanData, Endpoint localEndpoint) { + Map resourceAttributes = spanData.getResource().getAttributes(); + + // use the service.name from the Resource, if it's been set. + AttributeValue serviceNameValue = resourceAttributes.get(ResourceConstants.SERVICE_NAME); + if (serviceNameValue == null) { + return localEndpoint; + } + return Endpoint.newBuilder().serviceName(serviceNameValue.getStringValue()).build(); + } + + @Nullable + private static Span.Kind toSpanKind(SpanData spanData) { + // This is a hack because the Span API did not have SpanKind. + if (spanData.getKind() == Kind.SERVER + || (spanData.getKind() == null && Boolean.TRUE.equals(spanData.getHasRemoteParent()))) { + return Span.Kind.SERVER; + } + + // This is a hack because the Span API did not have SpanKind. + if (spanData.getKind() == Kind.CLIENT || spanData.getName().startsWith("Sent.")) { + return Span.Kind.CLIENT; + } + + if (spanData.getKind() == Kind.PRODUCER) { + return Span.Kind.PRODUCER; + } + if (spanData.getKind() == Kind.CONSUMER) { + return Span.Kind.CONSUMER; + } + + return null; + } + + private static long toEpochMicros(long epochNanos) { + return MICROSECONDS.convert(epochNanos, NANOSECONDS); + } + + private static String attributeValueToString(AttributeValue attributeValue) { + Type type = attributeValue.getType(); + switch (type) { + case STRING: + return attributeValue.getStringValue(); + case BOOLEAN: + return String.valueOf(attributeValue.getBooleanValue()); + case LONG: + return String.valueOf(attributeValue.getLongValue()); + case DOUBLE: + return String.valueOf(attributeValue.getDoubleValue()); + case STRING_ARRAY: + return commaSeparated(attributeValue.getStringArrayValue()); + case BOOLEAN_ARRAY: + return commaSeparated(attributeValue.getBooleanArrayValue()); + case LONG_ARRAY: + return commaSeparated(attributeValue.getLongArrayValue()); + case DOUBLE_ARRAY: + return commaSeparated(attributeValue.getDoubleArrayValue()); + } + throw new IllegalStateException("Unknown attribute type: " + type); + } + + private static String commaSeparated(List values) { + StringBuilder builder = new StringBuilder(); + for (Object value : values) { + if (builder.length() != 0) { + builder.append(','); + } + builder.append(value); + } + return builder.toString(); + } + + @Override + public ResultCode export(final Collection spanDataList) { + List encodedSpans = new ArrayList<>(spanDataList.size()); + for (SpanData spanData : spanDataList) { + encodedSpans.add(encoder.encode(generateSpan(spanData, localEndpoint))); + } + try { + sender.sendSpans(encodedSpans).execute(); + } catch (IOException e) { + return ResultCode.FAILURE; + } + return ResultCode.SUCCESS; + } + + @Override + public ResultCode flush() { + // nothing required here + return ResultCode.SUCCESS; + } + + @Override + public void shutdown() { + try { + sender.close(); + } catch (IOException e) { + logger.log(Level.WARNING, "Exception while closing the Zipkin Sender instance", e); + } + } + + /** + * Create a new {@link ZipkinSpanExporter} from the given configuration. + * + * @param configuration a {@link ZipkinExporterConfiguration} instance. + * @return A ready-to-use {@link ZipkinSpanExporter} + */ + public static ZipkinSpanExporter create(ZipkinExporterConfiguration configuration) { + return new ZipkinSpanExporter( + configuration.getEncoder(), configuration.getSender(), configuration.getServiceName()); + } +} diff --git a/exporters/zipkin/src/test/java/io/opentelemetry/exporters/zipkin/ZipkinExporterConfigurationTest.java b/exporters/zipkin/src/test/java/io/opentelemetry/exporters/zipkin/ZipkinExporterConfigurationTest.java new file mode 100644 index 00000000000..43e41a07d25 --- /dev/null +++ b/exporters/zipkin/src/test/java/io/opentelemetry/exporters/zipkin/ZipkinExporterConfigurationTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2020, 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. + */ + +package io.opentelemetry.exporters.zipkin; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import zipkin2.codec.SpanBytesEncoder; +import zipkin2.reporter.Sender; +import zipkin2.reporter.urlconnection.URLConnectionSender; + +/** Unit tests for {@link ZipkinExporterConfiguration}. */ +@RunWith(MockitoJUnitRunner.class) +public class ZipkinExporterConfigurationTest { + + private static final String SERVICE = "service"; + + @Mock private Sender mockSender; + + @Rule public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void verifyOptionsAreApplied() { + ZipkinExporterConfiguration configuration = + ZipkinExporterConfiguration.builder() + .setServiceName(SERVICE) + .setSender(mockSender) + .setEncoder(SpanBytesEncoder.PROTO3) + .build(); + assertThat(configuration.getServiceName()).isEqualTo(SERVICE); + assertThat(configuration.getSender()).isEqualTo(mockSender); + assertThat(configuration.getEncoder()).isEqualTo(SpanBytesEncoder.PROTO3); + } + + @Test + public void needToSpecifySender() { + ZipkinExporterConfiguration.Builder builder = + ZipkinExporterConfiguration.builder().setServiceName(SERVICE); + thrown.expect(IllegalStateException.class); + builder.build(); + } + + @Test + public void senderIsEnough() { + ZipkinExporterConfiguration.Builder builder = + ZipkinExporterConfiguration.builder().setServiceName("myServiceName").setSender(mockSender); + ZipkinExporterConfiguration configuration = builder.build(); + assertThat(configuration).isNotNull(); + assertThat(configuration.getSender()).isSameInstanceAs(mockSender); + assertThat(configuration.getServiceName()).isEqualTo("myServiceName"); + assertThat(configuration.getEncoder()).isEqualTo(SpanBytesEncoder.JSON_V2); + } + + @Test + public void urlIsEnough() { + ZipkinExporterConfiguration configuration = + ZipkinExporterConfiguration.builder() + .setEndpoint("https://myzipkin.endpoint") + .setServiceName("myServiceName") + .build(); + assertThat(configuration).isNotNull(); + assertThat(configuration.getSender()).isInstanceOf(URLConnectionSender.class); + assertThat(configuration.getServiceName()).isEqualTo("myServiceName"); + assertThat(configuration.getEncoder()).isEqualTo(SpanBytesEncoder.JSON_V2); + } +} diff --git a/exporters/zipkin/src/test/java/io/opentelemetry/exporters/zipkin/ZipkinSpanExporterTest.java b/exporters/zipkin/src/test/java/io/opentelemetry/exporters/zipkin/ZipkinSpanExporterTest.java new file mode 100644 index 00000000000..3b33b040bbc --- /dev/null +++ b/exporters/zipkin/src/test/java/io/opentelemetry/exporters/zipkin/ZipkinSpanExporterTest.java @@ -0,0 +1,303 @@ +/* + * Copyright 2020, 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. + */ + +package io.opentelemetry.exporters.zipkin; + +import static com.google.common.truth.Truth.assertThat; +import static io.opentelemetry.common.AttributeValue.stringAttributeValue; +import static java.util.Collections.singletonMap; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import io.opentelemetry.common.AttributeValue; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.resources.ResourceConstants; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.SpanData.Link; +import io.opentelemetry.sdk.trace.export.SpanExporter.ResultCode; +import io.opentelemetry.trace.Span.Kind; +import io.opentelemetry.trace.SpanId; +import io.opentelemetry.trace.Status; +import io.opentelemetry.trace.TraceFlags; +import io.opentelemetry.trace.TraceId; +import io.opentelemetry.trace.attributes.SemanticAttributes; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import zipkin2.Call; +import zipkin2.Endpoint; +import zipkin2.Span; +import zipkin2.codec.SpanBytesEncoder; +import zipkin2.reporter.Sender; + +/** Unit tests for {@link ZipkinSpanExporterTest}. */ +@RunWith(MockitoJUnitRunner.class) +public class ZipkinSpanExporterTest { + + @Mock private Sender mockSender; + @Mock private SpanBytesEncoder mockEncoder; + @Mock private Call mockZipkinCall; + + private static final Endpoint localEndpoint = + ZipkinSpanExporter.produceLocalEndpoint("tweetiebird"); + private static final String TRACE_ID = "d239036e7d5cec116b562147388b35bf"; + private static final String SPAN_ID = "9cc1e3049173be09"; + private static final String PARENT_SPAN_ID = "8b03ab423da481c5"; + private static final Map attributes = Collections.emptyMap(); + private static final List annotations = + ImmutableList.of( + SpanData.TimedEvent.create( + 1505855799_433901068L, "RECEIVED", Collections.emptyMap()), + SpanData.TimedEvent.create( + 1505855799_459486280L, "SENT", Collections.emptyMap())); + + @Test + public void generateSpan_remoteParent() { + SpanData data = buildStandardSpan().build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo(buildZipkinSpan(Span.Kind.SERVER)); + } + + @Test + public void generateSpan_ServerKind() { + SpanData data = buildStandardSpan().setKind(Kind.SERVER).build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo(buildZipkinSpan(Span.Kind.SERVER)); + } + + @Test + public void generateSpan_ClientKind() { + SpanData data = buildStandardSpan().setKind(Kind.CLIENT).build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo(buildZipkinSpan(Span.Kind.CLIENT)); + } + + @Test + public void generateSpan_InternalKind() { + SpanData data = buildStandardSpan().setKind(Kind.INTERNAL).build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo(buildZipkinSpan(null)); + } + + @Test + public void generateSpan_ConsumeKind() { + SpanData data = buildStandardSpan().setKind(Kind.CONSUMER).build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo(buildZipkinSpan(Span.Kind.CONSUMER)); + } + + @Test + public void generateSpan_ProducerKind() { + SpanData data = buildStandardSpan().setKind(Kind.PRODUCER).build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo(buildZipkinSpan(Span.Kind.PRODUCER)); + } + + @Test + public void generateSpan_ResourceServiceNameMapping() { + final Resource resource = + Resource.create( + singletonMap( + ResourceConstants.SERVICE_NAME, stringAttributeValue("super-zipkin-service"))); + SpanData data = buildStandardSpan().setResource(resource).build(); + + Endpoint expectedEndpoint = Endpoint.newBuilder().serviceName("super-zipkin-service").build(); + Span expectedZipkinSpan = + buildZipkinSpan(Span.Kind.SERVER).toBuilder().localEndpoint(expectedEndpoint).build(); + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)).isEqualTo(expectedZipkinSpan); + } + + @Test + public void generateSpan_WithAttributes() { + Map attributeMap = new HashMap<>(); + attributeMap.put("string", stringAttributeValue("string value")); + attributeMap.put("boolean", AttributeValue.booleanAttributeValue(false)); + attributeMap.put("long", AttributeValue.longAttributeValue(9999L)); + attributeMap.put("double", AttributeValue.doubleAttributeValue(222.333)); + attributeMap.put("booleanArray", AttributeValue.arrayAttributeValue(true, false)); + attributeMap.put("stringArray", AttributeValue.arrayAttributeValue("Hello")); + attributeMap.put("doubleArray", AttributeValue.arrayAttributeValue(32.33d, -98.3d)); + attributeMap.put("longArray", AttributeValue.arrayAttributeValue(33L, 999L)); + SpanData data = buildStandardSpan().setAttributes(attributeMap).setKind(Kind.CLIENT).build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo( + buildZipkinSpan(Span.Kind.CLIENT) + .toBuilder() + .putTag("string", "string value") + .putTag("boolean", "false") + .putTag("long", "9999") + .putTag("double", "222.333") + .putTag("booleanArray", "true,false") + .putTag("stringArray", "Hello") + .putTag("doubleArray", "32.33,-98.3") + .putTag("longArray", "33,999") + .build()); + } + + @Test + public void generateSpan_AlreadyHasHttpStatusInfo() { + Map attributeMap = new HashMap<>(); + attributeMap.put( + SemanticAttributes.HTTP_STATUS_CODE.key(), AttributeValue.longAttributeValue(404)); + attributeMap.put(SemanticAttributes.HTTP_STATUS_TEXT.key(), stringAttributeValue("NOT FOUND")); + attributeMap.put("error", stringAttributeValue("A user provided error")); + SpanData data = + buildStandardSpan() + .setAttributes(attributeMap) + .setKind(Kind.CLIENT) + .setStatus(Status.NOT_FOUND) + .build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo( + buildZipkinSpan(Span.Kind.CLIENT) + .toBuilder() + .clearTags() + .putTag(SemanticAttributes.HTTP_STATUS_CODE.key(), "404") + .putTag(SemanticAttributes.HTTP_STATUS_TEXT.key(), "NOT FOUND") + .putTag("error", "A user provided error") + .build()); + } + + @Test + public void generateSpan_WithRpcErrorStatus() { + Map attributeMap = new HashMap<>(); + attributeMap.put(SemanticAttributes.RPC_SERVICE.key(), stringAttributeValue("my service name")); + + String errorMessage = "timeout"; + + SpanData data = + buildStandardSpan() + .setStatus(Status.DEADLINE_EXCEEDED.withDescription(errorMessage)) + .setAttributes(attributeMap) + .build(); + + assertThat(ZipkinSpanExporter.generateSpan(data, localEndpoint)) + .isEqualTo( + buildZipkinSpan(Span.Kind.SERVER) + .toBuilder() + .putTag(ZipkinSpanExporter.GRPC_STATUS_DESCRIPTION, errorMessage) + .putTag(SemanticAttributes.RPC_SERVICE.key(), "my service name") + .putTag(ZipkinSpanExporter.GRPC_STATUS_CODE, "DEADLINE_EXCEEDED") + .putTag(ZipkinSpanExporter.STATUS_ERROR, "DEADLINE_EXCEEDED") + .build()); + } + + @Test + public void testExport() throws IOException { + ZipkinSpanExporter zipkinSpanExporter = + new ZipkinSpanExporter(mockEncoder, mockSender, "tweetiebird"); + + byte[] someBytes = new byte[0]; + when(mockEncoder.encode(buildZipkinSpan(Span.Kind.SERVER))).thenReturn(someBytes); + when(mockSender.sendSpans(Collections.singletonList(someBytes))).thenReturn(mockZipkinCall); + ResultCode resultCode = + zipkinSpanExporter.export(Collections.singleton(buildStandardSpan().build())); + + verify(mockZipkinCall).execute(); + assertThat(resultCode).isEqualTo(ResultCode.SUCCESS); + } + + @Test + public void testExport_failed() throws IOException { + ZipkinSpanExporter zipkinSpanExporter = + new ZipkinSpanExporter(mockEncoder, mockSender, "tweetiebird"); + + byte[] someBytes = new byte[0]; + when(mockEncoder.encode(buildZipkinSpan(Span.Kind.SERVER))).thenReturn(someBytes); + when(mockSender.sendSpans(Collections.singletonList(someBytes))).thenReturn(mockZipkinCall); + when(mockZipkinCall.execute()).thenThrow(new IOException()); + + ResultCode resultCode = + zipkinSpanExporter.export(Collections.singleton(buildStandardSpan().build())); + + assertThat(resultCode).isEqualTo(ResultCode.FAILURE); + } + + @Test + public void testCreate() { + ZipkinExporterConfiguration configuration = + ZipkinExporterConfiguration.builder() + .setSender(mockSender) + .setServiceName("myGreatService") + .build(); + + ZipkinSpanExporter exporter = ZipkinSpanExporter.create(configuration); + assertThat(exporter).isNotNull(); + } + + @Test + public void testShutdown() throws IOException { + ZipkinExporterConfiguration configuration = + ZipkinExporterConfiguration.builder() + .setServiceName("myGreatService") + .setSender(mockSender) + .build(); + + ZipkinSpanExporter exporter = ZipkinSpanExporter.create(configuration); + exporter.shutdown(); + verify(mockSender).close(); + } + + private static SpanData.Builder buildStandardSpan() { + return SpanData.newBuilder() + .setTraceId(TraceId.fromLowerBase16(TRACE_ID, 0)) + .setSpanId(SpanId.fromLowerBase16(SPAN_ID, 0)) + .setParentSpanId(SpanId.fromLowerBase16(PARENT_SPAN_ID, 0)) + .setTraceFlags(TraceFlags.builder().setIsSampled(true).build()) + .setStatus(Status.OK) + .setKind(Kind.SERVER) + .setHasRemoteParent(true) + .setName("Recv.helloworld.Greeter.SayHello") + .setStartEpochNanos(1505855794_194009601L) + .setAttributes(attributes) + .setTotalAttributeCount(attributes.size()) + .setTimedEvents(annotations) + .setLinks(Collections.emptyList()) + .setEndEpochNanos(1505855799_465726528L) + .setHasEnded(true); + } + + private static Span buildZipkinSpan(Span.Kind kind) { + return Span.newBuilder() + .traceId(TRACE_ID) + .parentId(PARENT_SPAN_ID) + .id(SPAN_ID) + .kind(kind) + .name("Recv.helloworld.Greeter.SayHello") + .timestamp(1505855794000000L + 194009601L / 1000) + .duration((1505855799000000L + 465726528L / 1000) - (1505855794000000L + 194009601L / 1000)) + .localEndpoint(localEndpoint) + .addAnnotation(1505855799000000L + 433901068L / 1000, "RECEIVED") + .addAnnotation(1505855799000000L + 459486280L / 1000, "SENT") + // .putTag(ZipkinSpanExporter.STATUS_CODE, status) + .build(); + } +} diff --git a/settings.gradle b/settings.gradle index 79cdff7a06a..090bd260069 100644 --- a/settings.gradle +++ b/settings.gradle @@ -30,6 +30,7 @@ include ":opentelemetry-all", ":opentelemetry-exporters-logging", ":opentelemetry-exporters-otlp", ":opentelemetry-exporters-prometheus", + ":opentelemetry-exporters-zipkin", ":opentelemetry-opentracing-shim", ":opentelemetry-proto", ":opentelemetry-sdk",