From 4a0ebce04f1fa84b27fa7b8aa24de30a632b064a Mon Sep 17 00:00:00 2001 From: Thomas Pierce Date: Wed, 29 Mar 2023 16:28:34 -0700 Subject: [PATCH] Add new components to allow for generating metrics from 100% of spans without impacting sampling In this commit, we are adding several new components to the awsxray package: * AlwaysRecordSampler - A simple aggregate sampler that always ensures spans are "recorded" (i.e. sent to span processors). This allows us to generate metrics from 100% of spans without impacting true sampling rate. * AwsSpanMetricsProcessor - A span processor that will generate specific metrics pertaining to latency, faults, and errors. Relies on a MetricAttributeGenerator to build attributes for the metrics. * AwsMetricAttributesSpanExporter - A span exporter that relies on MetricAttributeGenerator to wraps metric attributes around the span attributes before passing the span to a delegate span exporter for export. * MetricAttributeGenerator - A generic interface for components that consumes spans and resources and produces attributes for metrics generated from these spans. * AwsMetricAttributeGenerator - A specific implementation of MetricAttributeGenerator, used for generating AWS-specific attributes. * AwsSpanMetricsProcessorBuilder - A builder class for AwsSpanMetricsProcessor. * AwsMetricAttributesSpanExporterBuilder - A builder class for AwsMetricAttributesSpanExporter. Related issue: https://github.com/open-telemetry/opentelemetry-java-contrib/issues/789 --- .../contrib/awsxray/AlwaysRecordSampler.java | 84 +++++ .../contrib/awsxray/AwsAttributeKeys.java | 4 + .../awsxray/AwsMetricAttributeGenerator.java | 222 ++++++++++++ .../AwsMetricAttributesSpanExporter.java | 114 +++++++ ...wsMetricAttributesSpanExporterBuilder.java | 51 +++ .../awsxray/AwsSpanMetricsProcessor.java | 124 +++++++ .../AwsSpanMetricsProcessorBuilder.java | 81 +++++ .../awsxray/MetricAttributeGenerator.java | 29 ++ .../awsxray/AlwaysRecordSamplerTest.java | 108 ++++++ .../AwsMetricAttributeGeneratorTest.java | 319 ++++++++++++++++++ .../AwsMetricAttributesSpanExporterTest.java | 288 ++++++++++++++++ .../awsxray/AwsSpanMetricsProcessorTest.java | 239 +++++++++++++ 12 files changed, 1663 insertions(+) create mode 100644 aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AlwaysRecordSampler.java create mode 100644 aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributeGenerator.java create mode 100644 aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporter.java create mode 100644 aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporterBuilder.java create mode 100644 aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessor.java create mode 100644 aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessorBuilder.java create mode 100644 aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/MetricAttributeGenerator.java create mode 100644 aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AlwaysRecordSamplerTest.java create mode 100644 aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributeGeneratorTest.java create mode 100644 aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporterTest.java create mode 100644 aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessorTest.java diff --git a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AlwaysRecordSampler.java b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AlwaysRecordSampler.java new file mode 100644 index 000000000..48b26f007 --- /dev/null +++ b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AlwaysRecordSampler.java @@ -0,0 +1,84 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingDecision; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import java.util.List; +import javax.annotation.concurrent.Immutable; + +/** + * This sampler will return the sampling result of the provided {@link #rootSampler}, unless the + * sampling result contains the sampling decision {@link SamplingDecision#DROP}, in which case, a + * new sampling result will be returned that is functionally equivalent to the original, except that + * it contains the sampling decision {@link SamplingDecision#RECORD_ONLY}. This ensures that all + * spans are recorded, with no change to sampling. + * + *

The intended use case of this sampler is to provide a means of sending all spans to a + * processor without having an impact on the sampling rate. This may be desirable if a user wishes + * to count or otherwise measure all spans produced in a service, without incurring the cost of 100% + * sampling. + */ +@Immutable +public final class AlwaysRecordSampler implements Sampler { + + private final Sampler rootSampler; + + public static AlwaysRecordSampler create(Sampler rootSampler) { + return new AlwaysRecordSampler(rootSampler); + } + + private AlwaysRecordSampler(Sampler rootSampler) { + this.rootSampler = rootSampler; + } + + @Override + public SamplingResult shouldSample( + Context parentContext, + String traceId, + String name, + SpanKind spanKind, + Attributes attributes, + List parentLinks) { + SamplingResult result = + rootSampler.shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks); + if (result.getDecision() == SamplingDecision.DROP) { + result = wrapResultWithRecordOnlyResult(result); + } + + return result; + } + + @Override + public String getDescription() { + return "AlwaysRecordSampler{" + rootSampler.getDescription() + "}"; + } + + private static SamplingResult wrapResultWithRecordOnlyResult(SamplingResult result) { + return new SamplingResult() { + @Override + public SamplingDecision getDecision() { + return SamplingDecision.RECORD_ONLY; + } + + @Override + public Attributes getAttributes() { + return result.getAttributes(); + } + + @Override + public TraceState getUpdatedTraceState(TraceState parentTraceState) { + return result.getUpdatedTraceState(parentTraceState); + } + }; + } +} diff --git a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsAttributeKeys.java b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsAttributeKeys.java index c09c1f759..595a133a4 100644 --- a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsAttributeKeys.java +++ b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsAttributeKeys.java @@ -12,6 +12,10 @@ final class AwsAttributeKeys { private AwsAttributeKeys() {} + static final AttributeKey AWS_SPAN_KIND = AttributeKey.stringKey("aws.span.kind"); + + static final AttributeKey AWS_LOCAL_SERVICE = AttributeKey.stringKey("aws.local.service"); + static final AttributeKey AWS_LOCAL_OPERATION = AttributeKey.stringKey("aws.local.operation"); diff --git a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributeGenerator.java b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributeGenerator.java new file mode 100644 index 000000000..b51274f0f --- /dev/null +++ b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributeGenerator.java @@ -0,0 +1,222 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_LOCAL_OPERATION; +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_LOCAL_SERVICE; +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_REMOTE_OPERATION; +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_REMOTE_SERVICE; +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_SPAN_KIND; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.SERVICE_NAME; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.DB_OPERATION; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.DB_SYSTEM; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.FAAS_INVOKED_NAME; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.FAAS_INVOKED_PROVIDER; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.GRAPHQL_OPERATION_TYPE; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.MESSAGING_OPERATION; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.MESSAGING_SYSTEM; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.PEER_SERVICE; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.RPC_METHOD; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.RPC_SERVICE; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.semconv.resource.attributes.ResourceAttributes; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * AwsMetricAttributeGenerator generates very specific metric attributes based on low-cardinality + * span and resource attributes. If such attributes are not present, we fallback to default values. + * + *

The goal of these particular metric attributes is to get metrics for incoming and outgoing + * traffic for a service. Namely, {@link SpanKind#SERVER} and {@link SpanKind#CONSUMER} spans + * represent "incoming" traffic, {@link SpanKind#CLIENT} and {@link SpanKind#PRODUCER} spans + * represent "outgoing" traffic, and {@link SpanKind#INTERNAL} spans are ignored. + */ +final class AwsMetricAttributeGenerator implements MetricAttributeGenerator { + + private static final Logger logger = + Logger.getLogger(AwsMetricAttributeGenerator.class.getName()); + + // Special SERVICE attribute value if GRAPHQL_OPERATION_TYPE attribute key is present. + private static final String GRAPHQL = "graphql"; + + // Default attribute values if no valid span attribute value is identified + private static final String UNKNOWN_SERVICE = "UnknownService"; + private static final String UNKNOWN_OPERATION = "UnknownOperation"; + private static final String UNKNOWN_REMOTE_SERVICE = "UnknownRemoteService"; + private static final String UNKNOWN_REMOTE_OPERATION = "UnknownRemoteOperation"; + + @Override + public Attributes generateMetricAttributesFromSpan(SpanData span, Resource resource) { + AttributesBuilder builder = Attributes.builder(); + switch (span.getKind()) { + case CONSUMER: + case SERVER: + setService(resource, span, builder); + setIngressOperation(span, builder); + setSpanKind(span, builder); + break; + case PRODUCER: + case CLIENT: + setService(resource, span, builder); + setEgressOperation(span, builder); + setRemoteServiceAndOperation(span, builder); + setSpanKind(span, builder); + break; + default: + // Add no attributes, signalling no metrics should be emitted. + } + return builder.build(); + } + + /** Service is always derived from {@link ResourceAttributes#SERVICE_NAME} */ + private static void setService(Resource resource, SpanData span, AttributesBuilder builder) { + String service = resource.getAttribute(SERVICE_NAME); + if (service == null) { + logUnknownAttribute(AWS_LOCAL_SERVICE, span); + service = UNKNOWN_SERVICE; + } + builder.put(AWS_LOCAL_SERVICE, service); + } + + /** + * Ingress operation (i.e. operation for Server and Consumer spans) is always derived from span + * name. + */ + private static void setIngressOperation(SpanData span, AttributesBuilder builder) { + String operation = span.getName(); + if (operation == null) { + logUnknownAttribute(AWS_LOCAL_OPERATION, span); + operation = UNKNOWN_OPERATION; + } + builder.put(AWS_LOCAL_OPERATION, operation); + } + + /** + * Egress operation (i.e. operation for Client and Producer spans) is always derived from a + * special span attribute, {@link AwsAttributeKeys#AWS_LOCAL_OPERATION}. This attribute is + * generated with a separate SpanProcessor, {@link AttributePropagatingSpanProcessor} + */ + private static void setEgressOperation(SpanData span, AttributesBuilder builder) { + String operation = span.getAttributes().get(AWS_LOCAL_OPERATION); + if (operation == null) { + logUnknownAttribute(AWS_LOCAL_OPERATION, span); + operation = UNKNOWN_OPERATION; + } + builder.put(AWS_LOCAL_OPERATION, operation); + } + + /** + * Remote attributes (only for Client and Producer spans) are generated based on low-cardinality + * span attributes, in priority order. + * + *

The first priority is the AWS Remote attributes, which are generated from manually + * instrumented span attributes, and are clear indications of customer intent. If AWS Remote + * attributes are not present, the next highest priority span attribute is Peer Service, which is + * also a reliable indicator of customer intent. If this is set, it will override + * AWS_REMOTE_SERVICE identified from any other span attribute, other than AWS Remote attributes. + * + *

After this, we look for the following low-cardinality span attributes that can be used to + * determine the remote metric attributes: + * + *

+ * + *

In each case, these span attributes were selected from the OpenTelemetry trace semantic + * convention specifications as they adhere to the three following criteria: + * + *

+ * + * TODO: This specific logic may change in future. Specifically, we are still deciding which HTTP + * and RPC attributes to use here, but this is a sufficient starting point. + */ + private static void setRemoteServiceAndOperation(SpanData span, AttributesBuilder builder) { + if (isKeyPresent(span, AWS_REMOTE_SERVICE) || isKeyPresent(span, AWS_REMOTE_OPERATION)) { + setRemoteService(span, builder, AWS_REMOTE_SERVICE); + setRemoteOperation(span, builder, AWS_REMOTE_OPERATION); + } else if (isKeyPresent(span, RPC_SERVICE) || isKeyPresent(span, RPC_METHOD)) { + setRemoteService(span, builder, RPC_SERVICE); + setRemoteOperation(span, builder, RPC_METHOD); + } else if (isKeyPresent(span, DB_SYSTEM) || isKeyPresent(span, DB_OPERATION)) { + setRemoteService(span, builder, DB_SYSTEM); + setRemoteOperation(span, builder, DB_OPERATION); + } else if (isKeyPresent(span, FAAS_INVOKED_PROVIDER) || isKeyPresent(span, FAAS_INVOKED_NAME)) { + setRemoteService(span, builder, FAAS_INVOKED_PROVIDER); + setRemoteOperation(span, builder, FAAS_INVOKED_NAME); + } else if (isKeyPresent(span, MESSAGING_SYSTEM) || isKeyPresent(span, MESSAGING_OPERATION)) { + setRemoteService(span, builder, MESSAGING_SYSTEM); + setRemoteOperation(span, builder, MESSAGING_OPERATION); + } else if (isKeyPresent(span, GRAPHQL_OPERATION_TYPE)) { + builder.put(AWS_REMOTE_SERVICE, GRAPHQL); + setRemoteOperation(span, builder, GRAPHQL_OPERATION_TYPE); + } else { + logUnknownAttribute(AWS_REMOTE_SERVICE, span); + builder.put(AWS_REMOTE_SERVICE, UNKNOWN_REMOTE_SERVICE); + logUnknownAttribute(AWS_REMOTE_OPERATION, span); + builder.put(AWS_REMOTE_OPERATION, UNKNOWN_REMOTE_OPERATION); + } + + // Peer service takes priority as RemoteService over everything but AWS Remote. + if (isKeyPresent(span, PEER_SERVICE) && !isKeyPresent(span, AWS_REMOTE_SERVICE)) { + setRemoteService(span, builder, PEER_SERVICE); + } + } + + /** Span kind is needed for differentiating metrics in the EMF exporter */ + private static void setSpanKind(SpanData span, AttributesBuilder builder) { + String spanKind = span.getKind().name(); + builder.put(AWS_SPAN_KIND, spanKind); + } + + private static boolean isKeyPresent(SpanData span, AttributeKey key) { + return span.getAttributes().get(key) != null; + } + + private static void setRemoteService( + SpanData span, AttributesBuilder builder, AttributeKey remoteServiceKey) { + String remoteService = span.getAttributes().get(remoteServiceKey); + if (remoteService == null) { + logUnknownAttribute(AWS_REMOTE_SERVICE, span); + remoteService = UNKNOWN_REMOTE_SERVICE; + } + builder.put(AWS_REMOTE_SERVICE, remoteService); + } + + private static void setRemoteOperation( + SpanData span, AttributesBuilder builder, AttributeKey remoteOperationKey) { + String remoteOperation = span.getAttributes().get(remoteOperationKey); + if (remoteOperation == null) { + logUnknownAttribute(AWS_REMOTE_OPERATION, span); + remoteOperation = UNKNOWN_REMOTE_OPERATION; + } + builder.put(AWS_REMOTE_OPERATION, remoteOperation); + } + + private static void logUnknownAttribute(AttributeKey attributeKey, SpanData span) { + String[] params = { + attributeKey.getKey(), span.getKind().name(), span.getSpanContext().getSpanId() + }; + logger.log(Level.FINEST, "No valid {0} value found for {1} span {2}", params); + } +} diff --git a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporter.java b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporter.java new file mode 100644 index 000000000..e66f56cdb --- /dev/null +++ b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporter.java @@ -0,0 +1,114 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.DelegatingSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import javax.annotation.concurrent.Immutable; + +/** + * This exporter will update a span with metric attributes before exporting. It depends on a {@link + * SpanExporter} being provided on instantiation, which the AwsSpanMetricsExporter will delegate + * export to. Also, a {@link MetricAttributeGenerator} must be provided, which will provide a means + * to determine attributes which should be applied to the span. Finally, a {@link Resource} must be + * provided, which is used to generate metric attributes. + * + *

This exporter should be coupled with the {@link AwsSpanMetricsProcessor} using the same {@link + * MetricAttributeGenerator}. This will result in metrics and spans being produced with common + * attributes. + */ +@Immutable +public class AwsMetricAttributesSpanExporter implements SpanExporter { + + private final SpanExporter delegate; + private final MetricAttributeGenerator generator; + private final Resource resource; + + /** Use {@link AwsMetricAttributesSpanExporterBuilder} to construct this exporter. */ + static AwsMetricAttributesSpanExporter create( + SpanExporter delegate, MetricAttributeGenerator generator, Resource resource) { + return new AwsMetricAttributesSpanExporter(delegate, generator, resource); + } + + private AwsMetricAttributesSpanExporter( + SpanExporter delegate, MetricAttributeGenerator generator, Resource resource) { + this.delegate = delegate; + this.generator = generator; + this.resource = resource; + } + + @Override + public CompletableResultCode export(Collection spans) { + List modifiedSpans = addMetricAttributes(spans); + return delegate.export(modifiedSpans); + } + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + @Override + public void close() { + delegate.close(); + } + + private List addMetricAttributes(Collection spans) { + List modifiedSpans = new ArrayList<>(); + + for (SpanData span : spans) { + Attributes attributes = generator.generateMetricAttributesFromSpan(span, resource); + if (!attributes.isEmpty()) { + span = wrapSpanWithAttributes(span, attributes); + } + modifiedSpans.add(span); + } + + return modifiedSpans; + } + + /** + * {@link #export} works with a {@link SpanData}, which does not permit modification. However, we + * need to add derived metric attributes to the span. To work around this, we will wrap the + * SpanData with a {@link DelegatingSpanData} that simply passes through all API calls, except for + * those pertaining to Attributes, i.e. {@link SpanData#getAttributes()} and {@link + * SpanData#getTotalAttributeCount} APIs. + * + *

See https://github.com/open-telemetry/opentelemetry-specification/issues/1089 for more + * context on this approach. + */ + private static SpanData wrapSpanWithAttributes(SpanData span, Attributes attributes) { + Attributes originalAttributes = span.getAttributes(); + Attributes replacementAttributes = originalAttributes.toBuilder().putAll(attributes).build(); + + int originalTotalAttributeCount = span.getTotalAttributeCount(); + int replacementTotalAttributeCount = originalTotalAttributeCount + attributes.size(); + + return new DelegatingSpanData(span) { + @Override + public Attributes getAttributes() { + return replacementAttributes; + } + + @Override + public int getTotalAttributeCount() { + return replacementTotalAttributeCount; + } + }; + } +} diff --git a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporterBuilder.java b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporterBuilder.java new file mode 100644 index 000000000..e2a4e85d2 --- /dev/null +++ b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporterBuilder.java @@ -0,0 +1,51 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import static java.util.Objects.requireNonNull; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.export.SpanExporter; + +public class AwsMetricAttributesSpanExporterBuilder { + + // Defaults + private static final MetricAttributeGenerator DEFAULT_GENERATOR = + new AwsMetricAttributeGenerator(); + + // Required builder elements + private final SpanExporter delegate; + private final Resource resource; + + // Optional builder elements + private MetricAttributeGenerator generator = DEFAULT_GENERATOR; + + public static AwsMetricAttributesSpanExporterBuilder create( + SpanExporter delegate, Resource resource) { + return new AwsMetricAttributesSpanExporterBuilder(delegate, resource); + } + + private AwsMetricAttributesSpanExporterBuilder(SpanExporter delegate, Resource resource) { + this.delegate = delegate; + this.resource = resource; + } + + /** + * Sets the generator used to generate attributes used spancs exported by the exporter. If unset, + * defaults to {@link #DEFAULT_GENERATOR}. Must not be null. + */ + @CanIgnoreReturnValue + public AwsMetricAttributesSpanExporterBuilder setGenerator(MetricAttributeGenerator generator) { + requireNonNull(generator, "generator"); + this.generator = generator; + return this; + } + + public AwsMetricAttributesSpanExporter build() { + return AwsMetricAttributesSpanExporter.create(delegate, generator, resource); + } +} diff --git a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessor.java b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessor.java new file mode 100644 index 000000000..4fec1686e --- /dev/null +++ b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessor.java @@ -0,0 +1,124 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.HTTP_STATUS_CODE; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.data.SpanData; +import javax.annotation.concurrent.Immutable; + +/** + * This processor will generate metrics based on span data. It depends on a {@link + * MetricAttributeGenerator} being provided on instantiation, which will provide a means to + * determine attributes which should be used to create metrics. A {@link Resource} must also be + * provided, which is used to generate metrics. Finally, two {@link LongCounter}'s and a {@link + * DoubleHistogram} must be provided, which will be used to actually create desired metrics (see + * below) + * + *

AwsSpanMetricsProcessor produces metrics for errors (e.g. HTTP 4XX status codes), faults (e.g. + * HTTP 5XX status codes), and latency (in Milliseconds). Errors and faults are counted, while + * latency is measured with a histogram. Metrics are emitted with attributes derived from span + * attributes. + * + *

For highest fidelity metrics, this processor should be coupled with the {@link + * AlwaysRecordSampler}, which will result in 100% of spans being sent to the processor. + */ +@Immutable +public final class AwsSpanMetricsProcessor implements SpanProcessor { + + private static final double NANOS_TO_MILLIS = 1_000_000.0; + + // Constants for deriving error and fault metrics + private static final int ERROR_CODE_LOWER_BOUND = 400; + private static final int ERROR_CODE_UPPER_BOUND = 499; + private static final int FAULT_CODE_LOWER_BOUND = 500; + private static final int FAULT_CODE_UPPER_BOUND = 599; + + // Metric instruments + private final LongCounter errorCounter; + private final LongCounter faultCounter; + private final DoubleHistogram latencyHistogram; + + private final MetricAttributeGenerator generator; + private final Resource resource; + + /** Use {@link AwsSpanMetricsProcessorBuilder} to construct this processor. */ + static AwsSpanMetricsProcessor create( + LongCounter errorCounter, + LongCounter faultCounter, + DoubleHistogram latencyHistogram, + MetricAttributeGenerator generator, + Resource resource) { + return new AwsSpanMetricsProcessor( + errorCounter, faultCounter, latencyHistogram, generator, resource); + } + + private AwsSpanMetricsProcessor( + LongCounter errorCounter, + LongCounter faultCounter, + DoubleHistogram latencyHistogram, + MetricAttributeGenerator generator, + Resource resource) { + this.errorCounter = errorCounter; + this.faultCounter = faultCounter; + this.latencyHistogram = latencyHistogram; + this.generator = generator; + this.resource = resource; + } + + @Override + public void onStart(Context parentContext, ReadWriteSpan span) {} + + @Override + public boolean isStartRequired() { + return false; + } + + @Override + public void onEnd(ReadableSpan span) { + SpanData spanData = span.toSpanData(); + Attributes attributes = generator.generateMetricAttributesFromSpan(spanData, resource); + + // Only record metrics if non-empty attributes are returned. + if (!attributes.isEmpty()) { + recordErrorOrFault(span, attributes); + recordLatency(span, attributes); + } + } + + @Override + public boolean isEndRequired() { + return true; + } + + private void recordErrorOrFault(ReadableSpan span, Attributes attributes) { + Long httpStatusCode = span.getAttribute(HTTP_STATUS_CODE); + if (httpStatusCode == null) { + return; + } + + if (httpStatusCode >= ERROR_CODE_LOWER_BOUND && httpStatusCode <= ERROR_CODE_UPPER_BOUND) { + errorCounter.add(1, attributes); + } else if (httpStatusCode >= FAULT_CODE_LOWER_BOUND + && httpStatusCode <= FAULT_CODE_UPPER_BOUND) { + faultCounter.add(1, attributes); + } + } + + private void recordLatency(ReadableSpan span, Attributes attributes) { + long nanos = span.getLatencyNanos(); + double millis = nanos / NANOS_TO_MILLIS; + latencyHistogram.record(millis, attributes); + } +} diff --git a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessorBuilder.java b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessorBuilder.java new file mode 100644 index 000000000..703aff64a --- /dev/null +++ b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessorBuilder.java @@ -0,0 +1,81 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import static java.util.Objects.requireNonNull; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.sdk.resources.Resource; + +/** A builder for {@link AwsSpanMetricsProcessor} */ +public final class AwsSpanMetricsProcessorBuilder { + + // Metric instrument configuration constants + private static final String ERROR = "Error"; + private static final String FAULT = "Fault"; + private static final String LATENCY = "Latency"; + private static final String LATENCY_UNITS = "Milliseconds"; + + // Defaults + private static final MetricAttributeGenerator DEFAULT_GENERATOR = + new AwsMetricAttributeGenerator(); + private static final String DEFAULT_SCOPE_NAME = "AwsSpanMetricsProcessor"; + + // Required builder elements + private final MeterProvider meterProvider; + private final Resource resource; + + // Optional builder elements + private MetricAttributeGenerator generator = DEFAULT_GENERATOR; + private String scopeName = DEFAULT_SCOPE_NAME; + + public static AwsSpanMetricsProcessorBuilder create( + MeterProvider meterProvider, Resource resource) { + return new AwsSpanMetricsProcessorBuilder(meterProvider, resource); + } + + private AwsSpanMetricsProcessorBuilder(MeterProvider meterProvider, Resource resource) { + this.meterProvider = meterProvider; + this.resource = resource; + } + + /** + * Sets the generator used to generate attributes used in metrics produced by span metrics + * processor. If unset, defaults to {@link #DEFAULT_GENERATOR}. Must not be null. + */ + @CanIgnoreReturnValue + public AwsSpanMetricsProcessorBuilder setGenerator(MetricAttributeGenerator generator) { + requireNonNull(generator, "generator"); + this.generator = generator; + return this; + } + + /** + * Sets the scope name used in the creation of metrics by the span metrics processor. If unset, + * defaults to {@link #DEFAULT_SCOPE_NAME}. Must not be null. + */ + @CanIgnoreReturnValue + public AwsSpanMetricsProcessorBuilder setScopeName(String scopeName) { + requireNonNull(scopeName, "scopeName"); + this.scopeName = scopeName; + return this; + } + + public AwsSpanMetricsProcessor build() { + Meter meter = meterProvider.get(scopeName); + LongCounter errorCounter = meter.counterBuilder(ERROR).build(); + LongCounter faultCounter = meter.counterBuilder(FAULT).build(); + DoubleHistogram latencyHistogram = + meter.histogramBuilder(LATENCY).setUnit(LATENCY_UNITS).build(); + + return AwsSpanMetricsProcessor.create( + errorCounter, faultCounter, latencyHistogram, generator, resource); + } +} diff --git a/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/MetricAttributeGenerator.java b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/MetricAttributeGenerator.java new file mode 100644 index 000000000..28daba143 --- /dev/null +++ b/aws-xray/src/main/java/io/opentelemetry/contrib/awsxray/MetricAttributeGenerator.java @@ -0,0 +1,29 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.SpanData; + +/** + * Metric attribute generator defines an interface for classes that can generate specific attributes + * to be used by an {@link AwsSpanMetricsProcessor} to produce metrics and by {@link + * AwsMetricAttributesSpanExporter} to wrap the original span. + */ +public interface MetricAttributeGenerator { + + /** + * Given a span and associated resource, produce meaningful metric attributes for metrics produced + * from the span. If no metrics should be generated from this span, return {@link + * Attributes#empty()}. + * + * @param span - SpanData to be used to generate metric attributes. + * @param resource - Resource associated with Span to be used to generate metric attributes. + * @return A set of zero or more attributes. Must not return null. + */ + Attributes generateMetricAttributesFromSpan(SpanData span, Resource resource); +} diff --git a/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AlwaysRecordSamplerTest.java b/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AlwaysRecordSamplerTest.java new file mode 100644 index 000000000..42093c51f --- /dev/null +++ b/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AlwaysRecordSamplerTest.java @@ -0,0 +1,108 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceId; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingDecision; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link AlwaysRecordSampler}. */ +class AlwaysRecordSamplerTest { + + // Mocks + private Sampler mockSampler; + + private AlwaysRecordSampler sampler; + + @BeforeEach + void setUpSamplers() { + mockSampler = mock(Sampler.class); + sampler = AlwaysRecordSampler.create(mockSampler); + } + + @Test + void testGetDescription() { + when(mockSampler.getDescription()).thenReturn("mockDescription"); + assertThat(sampler.getDescription()).isEqualTo("AlwaysRecordSampler{mockDescription}"); + } + + @Test + void testRecordAndSampleSamplingDecision() { + validateShouldSample(SamplingDecision.RECORD_AND_SAMPLE, SamplingDecision.RECORD_AND_SAMPLE); + } + + @Test + void testRecordOnlySamplingDecision() { + validateShouldSample(SamplingDecision.RECORD_ONLY, SamplingDecision.RECORD_ONLY); + } + + @Test + void testDropSamplingDecision() { + validateShouldSample(SamplingDecision.DROP, SamplingDecision.RECORD_ONLY); + } + + private void validateShouldSample( + SamplingDecision rootDecision, SamplingDecision expectedDecision) { + SamplingResult rootResult = buildRootSamplingResult(rootDecision); + when(mockSampler.shouldSample(any(), anyString(), anyString(), any(), any(), any())) + .thenReturn(rootResult); + SamplingResult actualResult = + sampler.shouldSample( + Context.current(), + TraceId.fromLongs(1, 2), + "name", + SpanKind.CLIENT, + Attributes.empty(), + Collections.emptyList()); + + if (rootDecision.equals(expectedDecision)) { + assertThat(actualResult).isEqualTo(rootResult); + assertThat(actualResult.getDecision()).isEqualTo(rootDecision); + } else { + assertThat(actualResult).isNotEqualTo(rootResult); + assertThat(actualResult.getDecision()).isEqualTo(expectedDecision); + } + + assertThat(actualResult.getAttributes()).isEqualTo(rootResult.getAttributes()); + TraceState traceState = TraceState.builder().build(); + assertThat(actualResult.getUpdatedTraceState(traceState)) + .isEqualTo(rootResult.getUpdatedTraceState(traceState)); + } + + private static SamplingResult buildRootSamplingResult(SamplingDecision samplingDecision) { + return new SamplingResult() { + @Override + public SamplingDecision getDecision() { + return samplingDecision; + } + + @Override + public Attributes getAttributes() { + return Attributes.of(AttributeKey.stringKey("key"), samplingDecision.name()); + } + + @Override + public TraceState getUpdatedTraceState(TraceState parentTraceState) { + return TraceState.builder().put("key", samplingDecision.name()).build(); + } + }; + } +} diff --git a/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributeGeneratorTest.java b/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributeGeneratorTest.java new file mode 100644 index 000000000..c7d0a7c5a --- /dev/null +++ b/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributeGeneratorTest.java @@ -0,0 +1,319 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_LOCAL_OPERATION; +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_LOCAL_SERVICE; +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_REMOTE_OPERATION; +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_REMOTE_SERVICE; +import static io.opentelemetry.contrib.awsxray.AwsAttributeKeys.AWS_SPAN_KIND; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.SERVICE_NAME; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.DB_OPERATION; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.DB_SYSTEM; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.FAAS_INVOKED_NAME; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.FAAS_INVOKED_PROVIDER; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.GRAPHQL_OPERATION_TYPE; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.MESSAGING_OPERATION; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.MESSAGING_SYSTEM; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.PEER_SERVICE; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.RPC_METHOD; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.RPC_SERVICE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.SpanData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link AwsMetricAttributeGenerator}. */ +class AwsMetricAttributeGeneratorTest { + + private static final AwsMetricAttributeGenerator GENERATOR = new AwsMetricAttributeGenerator(); + + // String constants that are used many times in these tests. + private static final String AWS_LOCAL_OPERATION_VALUE = "AWS local operation"; + private static final String AWS_REMOTE_SERVICE_VALUE = "AWS remote service"; + private static final String AWS_REMOTE_OPERATION_VALUE = "AWS remote operation"; + private static final String SERVICE_NAME_VALUE = "Service name"; + private static final String SPAN_NAME_VALUE = "Span name"; + private static final String UNKNOWN_SERVICE = "UnknownService"; + private static final String UNKNOWN_OPERATION = "UnknownOperation"; + private static final String UNKNOWN_REMOTE_SERVICE = "UnknownRemoteService"; + private static final String UNKNOWN_REMOTE_OPERATION = "UnknownRemoteOperation"; + + private Attributes attributesMock; + private SpanData spanDataMock; + private Resource resource; + + @BeforeEach + public void setUpMocks() { + attributesMock = mock(Attributes.class); + spanDataMock = mock(SpanData.class); + when(spanDataMock.getAttributes()).thenReturn(attributesMock); + when(spanDataMock.getSpanContext()).thenReturn(mock(SpanContext.class)); + + resource = Resource.empty(); + } + + @Test + public void testConsumerSpanWithoutAttributes() { + Attributes expectedAttributes = + Attributes.of( + AWS_SPAN_KIND, SpanKind.CONSUMER.name(), + AWS_LOCAL_SERVICE, UNKNOWN_SERVICE, + AWS_LOCAL_OPERATION, UNKNOWN_OPERATION); + validateAttributesProducedForSpanOfKind(expectedAttributes, SpanKind.CONSUMER); + } + + @Test + public void testServerSpanWithoutAttributes() { + Attributes expectedAttributes = + Attributes.of( + AWS_SPAN_KIND, SpanKind.SERVER.name(), + AWS_LOCAL_SERVICE, UNKNOWN_SERVICE, + AWS_LOCAL_OPERATION, UNKNOWN_OPERATION); + validateAttributesProducedForSpanOfKind(expectedAttributes, SpanKind.SERVER); + } + + @Test + public void testProducerSpanWithoutAttributes() { + Attributes expectedAttributes = + Attributes.of( + AWS_SPAN_KIND, SpanKind.PRODUCER.name(), + AWS_LOCAL_SERVICE, UNKNOWN_SERVICE, + AWS_LOCAL_OPERATION, UNKNOWN_OPERATION, + AWS_REMOTE_SERVICE, UNKNOWN_REMOTE_SERVICE, + AWS_REMOTE_OPERATION, UNKNOWN_REMOTE_OPERATION); + validateAttributesProducedForSpanOfKind(expectedAttributes, SpanKind.PRODUCER); + } + + @Test + public void testClientSpanWithoutAttributes() { + Attributes expectedAttributes = + Attributes.of( + AWS_SPAN_KIND, SpanKind.CLIENT.name(), + AWS_LOCAL_SERVICE, UNKNOWN_SERVICE, + AWS_LOCAL_OPERATION, UNKNOWN_OPERATION, + AWS_REMOTE_SERVICE, UNKNOWN_REMOTE_SERVICE, + AWS_REMOTE_OPERATION, UNKNOWN_REMOTE_OPERATION); + validateAttributesProducedForSpanOfKind(expectedAttributes, SpanKind.CLIENT); + } + + @Test + public void testInternalSpan() { + // Spans with internal span kind should not produce any attributes. + validateAttributesProducedForSpanOfKind(Attributes.empty(), SpanKind.INTERNAL); + } + + @Test + public void testConsumerSpanWithAttributes() { + updateResourceWithServiceName(); + when(spanDataMock.getName()).thenReturn(SPAN_NAME_VALUE); + + Attributes expectedAttributes = + Attributes.of( + AWS_SPAN_KIND, SpanKind.CONSUMER.name(), + AWS_LOCAL_SERVICE, SERVICE_NAME_VALUE, + AWS_LOCAL_OPERATION, SPAN_NAME_VALUE); + validateAttributesProducedForSpanOfKind(expectedAttributes, SpanKind.CONSUMER); + } + + @Test + public void testServerSpanWithAttributes() { + updateResourceWithServiceName(); + when(spanDataMock.getName()).thenReturn(SPAN_NAME_VALUE); + + Attributes expectedAttributes = + Attributes.of( + AWS_SPAN_KIND, SpanKind.SERVER.name(), + AWS_LOCAL_SERVICE, SERVICE_NAME_VALUE, + AWS_LOCAL_OPERATION, SPAN_NAME_VALUE); + validateAttributesProducedForSpanOfKind(expectedAttributes, SpanKind.SERVER); + } + + @Test + public void testProducerSpanWithAttributes() { + updateResourceWithServiceName(); + mockAttribute(AWS_LOCAL_OPERATION, AWS_LOCAL_OPERATION_VALUE); + mockAttribute(AWS_REMOTE_SERVICE, AWS_REMOTE_SERVICE_VALUE); + mockAttribute(AWS_REMOTE_OPERATION, AWS_REMOTE_OPERATION_VALUE); + + Attributes expectedAttributes = + Attributes.of( + AWS_SPAN_KIND, SpanKind.PRODUCER.name(), + AWS_LOCAL_SERVICE, SERVICE_NAME_VALUE, + AWS_LOCAL_OPERATION, AWS_LOCAL_OPERATION_VALUE, + AWS_REMOTE_SERVICE, AWS_REMOTE_SERVICE_VALUE, + AWS_REMOTE_OPERATION, AWS_REMOTE_OPERATION_VALUE); + validateAttributesProducedForSpanOfKind(expectedAttributes, SpanKind.PRODUCER); + } + + @Test + public void testClientSpanWithAttributes() { + updateResourceWithServiceName(); + mockAttribute(AWS_LOCAL_OPERATION, AWS_LOCAL_OPERATION_VALUE); + mockAttribute(AWS_REMOTE_SERVICE, AWS_REMOTE_SERVICE_VALUE); + mockAttribute(AWS_REMOTE_OPERATION, AWS_REMOTE_OPERATION_VALUE); + + Attributes expectedAttributes = + Attributes.of( + AWS_SPAN_KIND, SpanKind.CLIENT.name(), + AWS_LOCAL_SERVICE, SERVICE_NAME_VALUE, + AWS_LOCAL_OPERATION, AWS_LOCAL_OPERATION_VALUE, + AWS_REMOTE_SERVICE, AWS_REMOTE_SERVICE_VALUE, + AWS_REMOTE_OPERATION, AWS_REMOTE_OPERATION_VALUE); + validateAttributesProducedForSpanOfKind(expectedAttributes, SpanKind.CLIENT); + } + + @Test + public void testRemoteAttributesCombinations() { + // Set all expected fields to a test string, we will overwrite them in descending order to test + // the priority-order logic in AwsMetricAttributeGenerator remote attribute methods. + mockAttribute(AWS_REMOTE_SERVICE, "TestString"); + mockAttribute(AWS_REMOTE_OPERATION, "TestString"); + mockAttribute(RPC_SERVICE, "TestString"); + mockAttribute(RPC_METHOD, "TestString"); + mockAttribute(DB_SYSTEM, "TestString"); + mockAttribute(DB_OPERATION, "TestString"); + mockAttribute(FAAS_INVOKED_PROVIDER, "TestString"); + mockAttribute(FAAS_INVOKED_NAME, "TestString"); + mockAttribute(MESSAGING_SYSTEM, "TestString"); + mockAttribute(MESSAGING_OPERATION, "TestString"); + mockAttribute(GRAPHQL_OPERATION_TYPE, "TestString"); + // Do not set dummy value for PEER_SERVICE, since it has special behaviour. + + // Two unused attributes to show that we will not make use of unrecognized attributes + mockAttribute(AttributeKey.stringKey("unknown.service.key"), "TestString"); + mockAttribute(AttributeKey.stringKey("unknown.operation.key"), "TestString"); + + // Validate behaviour of various combinations of AWS remote attributes, then remove them. + validateAndRemoveRemoteAttributes( + AWS_REMOTE_SERVICE, + AWS_REMOTE_SERVICE_VALUE, + AWS_REMOTE_OPERATION, + AWS_REMOTE_OPERATION_VALUE); + + // Validate behaviour of various combinations of RPC attributes, then remove them. + validateAndRemoveRemoteAttributes(RPC_SERVICE, "RPC service", RPC_METHOD, "RPC method"); + + // Validate behaviour of various combinations of DB attributes, then remove them. + validateAndRemoveRemoteAttributes(DB_SYSTEM, "DB system", DB_OPERATION, "DB operation"); + + // Validate behaviour of various combinations of FAAS attributes, then remove them. + validateAndRemoveRemoteAttributes( + FAAS_INVOKED_PROVIDER, "FAAS invoked provider", FAAS_INVOKED_NAME, "FAAS invoked name"); + + // Validate behaviour of various combinations of Messaging attributes, then remove them. + validateAndRemoveRemoteAttributes( + MESSAGING_SYSTEM, "Messaging system", MESSAGING_OPERATION, "Messaging operation"); + + // Validate behaviour of GraphQL operation type attribute, then remove it. + mockAttribute(GRAPHQL_OPERATION_TYPE, "GraphQL operation type"); + validateExpectedRemoteAttributes("graphql", "GraphQL operation type"); + mockAttribute(GRAPHQL_OPERATION_TYPE, null); + + // Validate behaviour of Peer service attribute, then remove it. + mockAttribute(PEER_SERVICE, "Peer service"); + validateExpectedRemoteAttributes("Peer service", UNKNOWN_REMOTE_OPERATION); + mockAttribute(PEER_SERVICE, null); + + // Once we have removed all usable metrics, we only have "unknown" attributes, which are unused. + validateExpectedRemoteAttributes(UNKNOWN_REMOTE_SERVICE, UNKNOWN_REMOTE_OPERATION); + } + + @Test + public void testPeerServiceDoesOverrideOtherRemoteServices() { + validatePeerServiceDoesOverride(RPC_SERVICE); + validatePeerServiceDoesOverride(DB_SYSTEM); + validatePeerServiceDoesOverride(FAAS_INVOKED_PROVIDER); + validatePeerServiceDoesOverride(MESSAGING_SYSTEM); + validatePeerServiceDoesOverride(GRAPHQL_OPERATION_TYPE); + // Actually testing that peer service overrides "UnknownRemoteService". + validatePeerServiceDoesOverride(AttributeKey.stringKey("unknown.service.key")); + } + + @Test + public void testPeerServiceDoesNotOverrideAwsRemoteService() { + mockAttribute(AWS_REMOTE_SERVICE, "TestString"); + mockAttribute(PEER_SERVICE, "PeerService"); + + when(spanDataMock.getKind()).thenReturn(SpanKind.CLIENT); + Attributes actualAttributes = + GENERATOR.generateMetricAttributesFromSpan(spanDataMock, resource); + assertThat(actualAttributes.get(AWS_REMOTE_SERVICE)).isEqualTo("TestString"); + } + + private void mockAttribute(AttributeKey key, String value) { + when(attributesMock.get(key)).thenReturn(value); + } + + private void validateAttributesProducedForSpanOfKind( + Attributes expectedAttributes, SpanKind kind) { + when(spanDataMock.getKind()).thenReturn(kind); + Attributes actualAttributes = + GENERATOR.generateMetricAttributesFromSpan(spanDataMock, resource); + assertThat(actualAttributes).isEqualTo(expectedAttributes); + } + + private void updateResourceWithServiceName() { + resource = Resource.builder().put(SERVICE_NAME, SERVICE_NAME_VALUE).build(); + } + + private void validateExpectedRemoteAttributes( + String expectedRemoteService, String expectedRemoteOperation) { + when(spanDataMock.getKind()).thenReturn(SpanKind.CLIENT); + Attributes actualAttributes = + GENERATOR.generateMetricAttributesFromSpan(spanDataMock, resource); + assertThat(actualAttributes.get(AWS_REMOTE_SERVICE)).isEqualTo(expectedRemoteService); + assertThat(actualAttributes.get(AWS_REMOTE_OPERATION)).isEqualTo(expectedRemoteOperation); + + when(spanDataMock.getKind()).thenReturn(SpanKind.PRODUCER); + actualAttributes = GENERATOR.generateMetricAttributesFromSpan(spanDataMock, resource); + assertThat(actualAttributes.get(AWS_REMOTE_SERVICE)).isEqualTo(expectedRemoteService); + assertThat(actualAttributes.get(AWS_REMOTE_OPERATION)).isEqualTo(expectedRemoteOperation); + } + + private void validateAndRemoveRemoteAttributes( + AttributeKey remoteServiceKey, + String remoteServiceValue, + AttributeKey remoteOperationKey, + String remoteOperationValue) { + mockAttribute(remoteServiceKey, remoteServiceValue); + mockAttribute(remoteOperationKey, remoteOperationValue); + validateExpectedRemoteAttributes(remoteServiceValue, remoteOperationValue); + + mockAttribute(remoteServiceKey, null); + mockAttribute(remoteOperationKey, remoteOperationValue); + validateExpectedRemoteAttributes(UNKNOWN_REMOTE_SERVICE, remoteOperationValue); + + mockAttribute(remoteServiceKey, remoteServiceValue); + mockAttribute(remoteOperationKey, null); + validateExpectedRemoteAttributes(remoteServiceValue, UNKNOWN_REMOTE_OPERATION); + + mockAttribute(remoteServiceKey, null); + mockAttribute(remoteOperationKey, null); + } + + private void validatePeerServiceDoesOverride(AttributeKey remoteServiceKey) { + mockAttribute(remoteServiceKey, "TestString"); + mockAttribute(PEER_SERVICE, "PeerService"); + + // Validate that peer service value takes precedence over whatever remoteServiceKey was set + when(spanDataMock.getKind()).thenReturn(SpanKind.CLIENT); + Attributes actualAttributes = + GENERATOR.generateMetricAttributesFromSpan(spanDataMock, resource); + assertThat(actualAttributes.get(AWS_REMOTE_SERVICE)).isEqualTo("PeerService"); + + mockAttribute(remoteServiceKey, null); + mockAttribute(PEER_SERVICE, null); + } +} diff --git a/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporterTest.java b/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporterTest.java new file mode 100644 index 000000000..2c98feacc --- /dev/null +++ b/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsMetricAttributesSpanExporterTest.java @@ -0,0 +1,288 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.MockitoAnnotations; + +/** Unit tests for {@link AwsSpanMetricsProcessor}. */ +class AwsMetricAttributesSpanExporterTest { + + @Captor private static ArgumentCaptor> delegateExportCaptor; + + // Test constants + private static final boolean CONTAINS_ATTRIBUTES = true; + private static final boolean CONTAINS_NO_ATTRIBUTES = false; + + // Resource is not mockable, but tests can safely rely on an empty resource. + private static final Resource testResource = Resource.empty(); + + // Mocks required for tests. + private MetricAttributeGenerator generatorMock; + private SpanExporter delegateMock; + + private AwsMetricAttributesSpanExporter awsMetricAttributesSpanExporter; + + @BeforeEach + public void setUpMocks() { + MockitoAnnotations.openMocks(this); + generatorMock = mock(MetricAttributeGenerator.class); + delegateMock = mock(SpanExporter.class); + + awsMetricAttributesSpanExporter = + AwsMetricAttributesSpanExporter.create(delegateMock, generatorMock, testResource); + } + + @Test + public void testPassthroughDelegations() { + awsMetricAttributesSpanExporter.flush(); + awsMetricAttributesSpanExporter.shutdown(); + awsMetricAttributesSpanExporter.close(); + verify(delegateMock, times(1)).flush(); + verify(delegateMock, times(1)).shutdown(); + verify(delegateMock, times(1)).close(); + } + + @Test + public void testExportDelegationWithoutAttributeOrModification() { + Attributes spanAttributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + SpanData spanDataMock = buildSpanDataMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export(Collections.singletonList(spanDataMock)); + verify(delegateMock, times(1)).export(delegateExportCaptor.capture()); + Collection exportedSpans = delegateExportCaptor.getValue(); + assertThat(exportedSpans.size()).isEqualTo(1); + + SpanData exportedSpan = (SpanData) exportedSpans.toArray()[0]; + assertThat(exportedSpan).isEqualTo(spanDataMock); + } + + @Test + public void testExportDelegationWithAttributeButWithoutModification() { + Attributes spanAttributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + SpanData spanDataMock = buildSpanDataMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export(Collections.singletonList(spanDataMock)); + verify(delegateMock, times(1)).export(delegateExportCaptor.capture()); + Collection exportedSpans = delegateExportCaptor.getValue(); + assertThat(exportedSpans.size()).isEqualTo(1); + + SpanData exportedSpan = (SpanData) exportedSpans.toArray()[0]; + assertThat(exportedSpan).isEqualTo(spanDataMock); + } + + @Test + public void testExportDelegationWithoutAttributeButWithModification() { + Attributes spanAttributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + SpanData spanDataMock = buildSpanDataMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export(Collections.singletonList(spanDataMock)); + verify(delegateMock, times(1)).export(delegateExportCaptor.capture()); + List exportedSpans = (List) delegateExportCaptor.getValue(); + assertThat(exportedSpans.size()).isEqualTo(1); + + SpanData exportedSpan = exportedSpans.get(0); + assertThat(exportedSpan.getClass()).isNotEqualTo(spanDataMock.getClass()); + assertThat(exportedSpan.getTotalAttributeCount()).isEqualTo(metricAttributes.size()); + Attributes exportedAttributes = exportedSpan.getAttributes(); + assertThat(exportedAttributes.size()).isEqualTo(metricAttributes.size()); + metricAttributes.forEach((k, v) -> assertThat(exportedAttributes.get(k)).isEqualTo(v)); + } + + @Test + public void testExportDelegationWithAttributeAndModification() { + Attributes spanAttributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + SpanData spanDataMock = buildSpanDataMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export(Collections.singletonList(spanDataMock)); + verify(delegateMock, times(1)).export(delegateExportCaptor.capture()); + List exportedSpans = (List) delegateExportCaptor.getValue(); + assertThat(exportedSpans.size()).isEqualTo(1); + + SpanData exportedSpan = exportedSpans.get(0); + assertThat(exportedSpan.getClass()).isNotEqualTo(spanDataMock.getClass()); + int expectedAttributeCount = metricAttributes.size() + spanAttributes.size(); + assertThat(exportedSpan.getTotalAttributeCount()).isEqualTo(expectedAttributeCount); + Attributes exportedAttributes = exportedSpan.getAttributes(); + assertThat(exportedAttributes.size()).isEqualTo(expectedAttributeCount); + spanAttributes.forEach((k, v) -> assertThat(exportedAttributes.get(k)).isEqualTo(v)); + metricAttributes.forEach((k, v) -> assertThat(exportedAttributes.get(k)).isEqualTo(v)); + } + + @Test + public void testExportDelegationWithMultipleSpans() { + Attributes spanAttributes1 = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + SpanData spanDataMock1 = buildSpanDataMock(spanAttributes1); + Attributes metricAttributes1 = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForExport(spanDataMock1, metricAttributes1); + + Attributes spanAttributes2 = buildSpanAttributes(CONTAINS_ATTRIBUTES); + SpanData spanDataMock2 = buildSpanDataMock(spanAttributes2); + Attributes metricAttributes2 = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForExport(spanDataMock2, metricAttributes2); + + Attributes spanAttributes3 = buildSpanAttributes(CONTAINS_ATTRIBUTES); + SpanData spanDataMock3 = buildSpanDataMock(spanAttributes3); + Attributes metricAttributes3 = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForExport(spanDataMock3, metricAttributes3); + + awsMetricAttributesSpanExporter.export( + Arrays.asList(spanDataMock1, spanDataMock2, spanDataMock3)); + verify(delegateMock, times(1)).export(delegateExportCaptor.capture()); + List exportedSpans = (List) delegateExportCaptor.getValue(); + assertThat(exportedSpans.size()).isEqualTo(3); + + SpanData exportedSpan1 = exportedSpans.get(0); + SpanData exportedSpan2 = exportedSpans.get(1); + SpanData exportedSpan3 = exportedSpans.get(2); + + assertThat(exportedSpan1).isEqualTo(spanDataMock1); + assertThat(exportedSpan3).isEqualTo(spanDataMock3); + + assertThat(exportedSpan2.getClass()).isNotEqualTo(spanDataMock2.getClass()); + int expectedAttributeCount = metricAttributes2.size() + spanAttributes2.size(); + assertThat(exportedSpan2.getTotalAttributeCount()).isEqualTo(expectedAttributeCount); + Attributes exportedAttributes = exportedSpan2.getAttributes(); + assertThat(exportedAttributes.size()).isEqualTo(expectedAttributeCount); + spanAttributes2.forEach((k, v) -> assertThat(exportedAttributes.get(k)).isEqualTo(v)); + metricAttributes2.forEach((k, v) -> assertThat(exportedAttributes.get(k)).isEqualTo(v)); + } + + @Test + public void testExportDelegatingSpanDataBehaviour() { + Attributes spanAttributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + SpanData spanDataMock = buildSpanDataMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export(Collections.singletonList(spanDataMock)); + verify(delegateMock, times(1)).export(delegateExportCaptor.capture()); + List exportedSpans = (List) delegateExportCaptor.getValue(); + assertThat(exportedSpans.size()).isEqualTo(1); + + SpanData exportedSpan = exportedSpans.get(0); + + SpanContext spanContextMock = mock(SpanContext.class); + when(spanDataMock.getSpanContext()).thenReturn(spanContextMock); + assertThat(exportedSpan.getSpanContext()).isEqualTo(spanContextMock); + + SpanContext parentSpanContextMock = mock(SpanContext.class); + when(spanDataMock.getParentSpanContext()).thenReturn(parentSpanContextMock); + assertThat(exportedSpan.getParentSpanContext()).isEqualTo(parentSpanContextMock); + + when(spanDataMock.getResource()).thenReturn(testResource); + assertThat(exportedSpan.getResource()).isEqualTo(testResource); + + // InstrumentationLibraryInfo is deprecated, so actually invoking it causes build failures. + // Excluding from this test. + + InstrumentationScopeInfo testInstrumentationScopeInfo = InstrumentationScopeInfo.empty(); + when(spanDataMock.getInstrumentationScopeInfo()).thenReturn(testInstrumentationScopeInfo); + assertThat(exportedSpan.getInstrumentationScopeInfo()).isEqualTo(testInstrumentationScopeInfo); + + String testName = "name"; + when(spanDataMock.getName()).thenReturn(testName); + assertThat(exportedSpan.getName()).isEqualTo(testName); + + SpanKind kindMock = mock(SpanKind.class); + when(spanDataMock.getKind()).thenReturn(kindMock); + assertThat(exportedSpan.getKind()).isEqualTo(kindMock); + + long testStartEpochNanos = 1L; + when(spanDataMock.getStartEpochNanos()).thenReturn(testStartEpochNanos); + assertThat(exportedSpan.getStartEpochNanos()).isEqualTo(testStartEpochNanos); + + List eventsMock = Collections.singletonList(mock(EventData.class)); + when(spanDataMock.getEvents()).thenReturn(eventsMock); + assertThat(exportedSpan.getEvents()).isEqualTo(eventsMock); + + List linksMock = Collections.singletonList(mock(LinkData.class)); + when(spanDataMock.getLinks()).thenReturn(linksMock); + assertThat(exportedSpan.getLinks()).isEqualTo(linksMock); + + StatusData statusMock = mock(StatusData.class); + when(spanDataMock.getStatus()).thenReturn(statusMock); + assertThat(exportedSpan.getStatus()).isEqualTo(statusMock); + + long testEndEpochNanosMock = 2L; + when(spanDataMock.getEndEpochNanos()).thenReturn(testEndEpochNanosMock); + assertThat(exportedSpan.getEndEpochNanos()).isEqualTo(testEndEpochNanosMock); + + when(spanDataMock.hasEnded()).thenReturn(true); + assertThat(exportedSpan.hasEnded()).isEqualTo(true); + + int testTotalRecordedEventsMock = 3; + when(spanDataMock.getTotalRecordedEvents()).thenReturn(testTotalRecordedEventsMock); + assertThat(exportedSpan.getTotalRecordedEvents()).isEqualTo(testTotalRecordedEventsMock); + + int testTotalRecordedLinksMock = 4; + when(spanDataMock.getTotalRecordedLinks()).thenReturn(testTotalRecordedLinksMock); + assertThat(exportedSpan.getTotalRecordedLinks()).isEqualTo(testTotalRecordedLinksMock); + } + + private static Attributes buildSpanAttributes(boolean containsAttribute) { + if (containsAttribute) { + return Attributes.of(AttributeKey.stringKey("original key"), "original value"); + } else { + return Attributes.empty(); + } + } + + private static Attributes buildMetricAttributes(boolean containsAttribute) { + if (containsAttribute) { + return Attributes.of(AttributeKey.stringKey("new key"), "new value"); + } else { + return Attributes.empty(); + } + } + + private static SpanData buildSpanDataMock(Attributes spanAttributes) { + // Configure spanData + SpanData mockSpanData = mock(SpanData.class); + when(mockSpanData.getAttributes()).thenReturn(spanAttributes); + when(mockSpanData.getTotalAttributeCount()).thenReturn(spanAttributes.size()); + return mockSpanData; + } + + private void configureMocksForExport(SpanData spanDataMock, Attributes metricAttributes) { + // Configure generated attributes + when(generatorMock.generateMetricAttributesFromSpan(eq(spanDataMock), eq(testResource))) + .thenReturn(metricAttributes); + } +} diff --git a/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessorTest.java b/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessorTest.java new file mode 100644 index 000000000..6f6fd2b97 --- /dev/null +++ b/aws-xray/src/test/java/io/opentelemetry/contrib/awsxray/AwsSpanMetricsProcessorTest.java @@ -0,0 +1,239 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.awsxray; + +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.HTTP_STATUS_CODE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.data.SpanData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link AwsSpanMetricsProcessor}. */ +class AwsSpanMetricsProcessorTest { + + // Test constants + private static final boolean CONTAINS_ATTRIBUTES = true; + private static final boolean CONTAINS_NO_ATTRIBUTES = false; + private static final double TEST_LATENCY_MILLIS = 150.0; + private static final long TEST_LATENCY_NANOS = 150_000_000L; + + // Resource is not mockable, but tests can safely rely on an empty resource. + private static final Resource testResource = Resource.empty(); + + // Useful enum for indicating expected HTTP status code-related metrics + private enum ExpectedStatusMetric { + ERROR, + FAULT, + NEITHER + } + + // Mocks required for tests. + private LongCounter errorCounterMock; + private LongCounter faultCounterMock; + private DoubleHistogram latencyHistogramMock; + private MetricAttributeGenerator generatorMock; + + private AwsSpanMetricsProcessor awsSpanMetricsProcessor; + + @BeforeEach + public void setUpMocks() { + errorCounterMock = mock(LongCounter.class); + faultCounterMock = mock(LongCounter.class); + latencyHistogramMock = mock(DoubleHistogram.class); + generatorMock = mock(MetricAttributeGenerator.class); + + awsSpanMetricsProcessor = + AwsSpanMetricsProcessor.create( + errorCounterMock, faultCounterMock, latencyHistogramMock, generatorMock, testResource); + } + + @Test + public void testIsRequired() { + assertThat(awsSpanMetricsProcessor.isStartRequired()).isFalse(); + assertThat(awsSpanMetricsProcessor.isEndRequired()).isTrue(); + } + + @Test + public void testStartDoesNothingToSpan() { + Context parentContextMock = mock(Context.class); + ReadWriteSpan spanMock = mock(ReadWriteSpan.class); + awsSpanMetricsProcessor.onStart(parentContextMock, spanMock); + verifyNoInteractions(parentContextMock, spanMock); + } + + @Test + public void testTearDown() { + assertThat(awsSpanMetricsProcessor.shutdown()).isEqualTo(CompletableResultCode.ofSuccess()); + assertThat(awsSpanMetricsProcessor.forceFlush()).isEqualTo(CompletableResultCode.ofSuccess()); + + // Not really much to test, just check that it doesn't cause issues/throw anything. + awsSpanMetricsProcessor.close(); + } + + /** + * Tests starting with testOnEndMetricsGeneration are testing the logic in + * AwsSpanMetricsProcessor's onEnd method pertaining to metrics generation. + */ + @Test + public void testOnEndMetricsGenerationWithoutSpanAttributes() { + Attributes spanAttributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + ReadableSpan readableSpanMock = buildReadableSpanMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForOnEnd(readableSpanMock, metricAttributes); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyNoInteractions(errorCounterMock); + verifyNoInteractions(faultCounterMock); + verify(latencyHistogramMock, times(1)).record(eq(TEST_LATENCY_MILLIS), eq(metricAttributes)); + } + + @Test + public void testOnEndMetricsGenerationWithoutMetricAttributes() { + Attributes spanAttributes = Attributes.of(HTTP_STATUS_CODE, 500L); + ReadableSpan readableSpanMock = buildReadableSpanMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForOnEnd(readableSpanMock, metricAttributes); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyNoInteractions(errorCounterMock); + verifyNoInteractions(faultCounterMock); + verifyNoInteractions(latencyHistogramMock); + } + + @Test + public void testOnEndMetricsGenerationWithoutEndRequired() { + Attributes spanAttributes = Attributes.of(HTTP_STATUS_CODE, 500L); + ReadableSpan readableSpanMock = buildReadableSpanMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForOnEnd(readableSpanMock, metricAttributes); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyNoInteractions(errorCounterMock); + verify(faultCounterMock, times(1)).add(eq(1L), eq(metricAttributes)); + verify(latencyHistogramMock, times(1)).record(eq(TEST_LATENCY_MILLIS), eq(metricAttributes)); + } + + @Test + public void testOnEndMetricsGenerationWithLatency() { + Attributes spanAttributes = Attributes.of(HTTP_STATUS_CODE, 200L); + ReadableSpan readableSpanMock = buildReadableSpanMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForOnEnd(readableSpanMock, metricAttributes); + + when(readableSpanMock.getLatencyNanos()).thenReturn(5_500_000L); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyNoInteractions(errorCounterMock); + verifyNoInteractions(faultCounterMock); + verify(latencyHistogramMock, times(1)).record(eq(5.5), eq(metricAttributes)); + } + + @Test + public void testOnEndMetricsGenerationWithStatusCodes() { + // Invalid HTTP status codes + validateMetricsGeneratedForHttpStatusCode(null, ExpectedStatusMetric.NEITHER); + + // Valid HTTP status codes + validateMetricsGeneratedForHttpStatusCode(200L, ExpectedStatusMetric.NEITHER); + validateMetricsGeneratedForHttpStatusCode(399L, ExpectedStatusMetric.NEITHER); + validateMetricsGeneratedForHttpStatusCode(400L, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForHttpStatusCode(499L, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForHttpStatusCode(500L, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForHttpStatusCode(599L, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForHttpStatusCode(600L, ExpectedStatusMetric.NEITHER); + } + + private static Attributes buildSpanAttributes(boolean containsAttribute) { + if (containsAttribute) { + return Attributes.of(AttributeKey.stringKey("original key"), "original value"); + } else { + return Attributes.empty(); + } + } + + private static Attributes buildMetricAttributes(boolean containsAttribute) { + if (containsAttribute) { + return Attributes.of(AttributeKey.stringKey("new key"), "new value"); + } else { + return Attributes.empty(); + } + } + + private static ReadableSpan buildReadableSpanMock(Attributes spanAttributes) { + ReadableSpan readableSpanMock = mock(ReadableSpan.class); + + // Configure latency + when(readableSpanMock.getLatencyNanos()).thenReturn(TEST_LATENCY_NANOS); + + // Configure attributes + when(readableSpanMock.getAttribute(any())) + .thenAnswer(invocation -> spanAttributes.get(invocation.getArgument(0))); + + // Configure spanData + SpanData mockSpanData = mock(SpanData.class); + when(mockSpanData.getAttributes()).thenReturn(spanAttributes); + when(mockSpanData.getTotalAttributeCount()).thenReturn(spanAttributes.size()); + when(readableSpanMock.toSpanData()).thenReturn(mockSpanData); + + return readableSpanMock; + } + + private void configureMocksForOnEnd(ReadableSpan readableSpanMock, Attributes metricAttributes) { + // Configure generated attributes + when(generatorMock.generateMetricAttributesFromSpan( + eq(readableSpanMock.toSpanData()), eq(testResource))) + .thenReturn(metricAttributes); + } + + private void validateMetricsGeneratedForHttpStatusCode( + Long httpStatusCode, ExpectedStatusMetric expectedStatusMetric) { + Attributes spanAttributes = Attributes.of(HTTP_STATUS_CODE, httpStatusCode); + ReadableSpan readableSpanMock = buildReadableSpanMock(spanAttributes); + Attributes metricAttributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForOnEnd(readableSpanMock, metricAttributes); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + switch (expectedStatusMetric) { + case ERROR: + verify(errorCounterMock, times(1)).add(eq(1L), eq(metricAttributes)); + verifyNoInteractions(faultCounterMock); + break; + case FAULT: + verifyNoInteractions(errorCounterMock); + verify(faultCounterMock, times(1)).add(eq(1L), eq(metricAttributes)); + break; + case NEITHER: + verifyNoInteractions(errorCounterMock); + verifyNoInteractions(faultCounterMock); + break; + } + + verify(latencyHistogramMock, times(1)).record(eq(TEST_LATENCY_MILLIS), eq(metricAttributes)); + + // Clear invocations so this method can be called multiple times in one test. + clearInvocations(errorCounterMock); + clearInvocations(faultCounterMock); + clearInvocations(latencyHistogramMock); + } +}