diff --git a/instrumentation/jmx-metrics/javaagent/README.md b/instrumentation/jmx-metrics/javaagent/README.md index 6bd867d6d9..be7446a5d7 100644 --- a/instrumentation/jmx-metrics/javaagent/README.md +++ b/instrumentation/jmx-metrics/javaagent/README.md @@ -302,7 +302,7 @@ rules: unit: By metricAttribute: jvm.memory.pool.name : param(name) - jvm.memory.type: lowercase(beanattr(type)) + jvm.memory.type: lowercase(beanattr(Type)) ``` For now, only the `lowercase` transformation is supported, other additions might be added in the future if needed. diff --git a/instrumentation/jmx-metrics/library/build.gradle.kts b/instrumentation/jmx-metrics/library/build.gradle.kts index 375e5e77d9..86f0cb0515 100644 --- a/instrumentation/jmx-metrics/library/build.gradle.kts +++ b/instrumentation/jmx-metrics/library/build.gradle.kts @@ -1,3 +1,5 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + plugins { id("otel.library-instrumentation") } @@ -6,4 +8,28 @@ dependencies { implementation("org.snakeyaml:snakeyaml-engine") testImplementation(project(":testing-common")) + testImplementation("org.testcontainers:testcontainers") + + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("com.linecorp.armeria:armeria-junit5:1.31.3") + testImplementation("com.linecorp.armeria:armeria-junit5:1.31.3") + testImplementation("com.linecorp.armeria:armeria-grpc:1.31.3") + testImplementation("io.opentelemetry.proto:opentelemetry-proto:1.5.0-alpha") +} + +tasks { + test { + // get packaged agent jar for testing + val shadowTask = project(":javaagent").tasks.named("shadowJar").get() + + dependsOn(shadowTask) + + inputs.files(layout.files(shadowTask)) + .withPropertyName("javaagent") + .withNormalizer(ClasspathNormalizer::class) + + doFirst { + jvmArgs("-Dio.opentelemetry.javaagent.path=${shadowTask.archiveFile.get()}") + } + } } diff --git a/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/MetricsVerifier.java b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/MetricsVerifier.java new file mode 100644 index 0000000000..b89659806d --- /dev/null +++ b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/MetricsVerifier.java @@ -0,0 +1,124 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.jmx.rules; + +import static io.opentelemetry.instrumentation.jmx.rules.assertions.JmxAssertj.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.opentelemetry.instrumentation.jmx.rules.assertions.MetricAssert; +import io.opentelemetry.proto.metrics.v1.Metric; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class MetricsVerifier { + + private final Map> assertions = new HashMap<>(); + private boolean strictMode = true; + + private MetricsVerifier() {} + + /** + * Create instance of MetricsVerifier configured to fail verification if any metric was not + * verified because there is no assertion defined for it. This behavior can be changed by calling + * {@link #disableStrictMode()} method. + * + * @return new instance of MetricsVerifier + * @see #disableStrictMode() + */ + public static MetricsVerifier create() { + return new MetricsVerifier(); + } + + /** + * Disable strict checks of metric assertions. It means that all metrics checks added after + * calling this method will not enforce asserting all metric properties and will not detect + * duplicate property assertions. Also, there will be no error reported if any of metrics was + * skipped because no assertion was added for it. + * + * @return this + * @see #verify(List) + * @see #add(String, Consumer) + */ + @CanIgnoreReturnValue + public MetricsVerifier disableStrictMode() { + strictMode = false; + return this; + } + + /** + * Add assertion for given metric + * + * @param metricName name of metric to be verified by provided assertion + * @param assertion an assertion to verify properties of the metric + * @return this + */ + @CanIgnoreReturnValue + public MetricsVerifier add(String metricName, Consumer assertion) { + if (assertions.containsKey(metricName)) { + throw new IllegalArgumentException("Duplicate assertion for metric " + metricName); + } + assertions.put( + metricName, + metric -> { + MetricAssert metricAssert = assertThat(metric); + metricAssert.setStrict(strictMode); + assertion.accept(metricAssert); + metricAssert.strictCheck(); + }); + return this; + } + + /** + * Execute all defined assertions against provided list of metrics. Error is reported if any of + * defined assertions failed. Error is also reported if any of expected metrics was not present in + * the metrics list, unless strict mode is disabled with {@link #disableStrictMode()}. + * + * @param metrics list of metrics to be verified + * @see #add(String, Consumer) + * @see #disableStrictMode() + */ + public void verify(List metrics) { + verifyAllExpectedMetricsWereReceived(metrics); + + Set unverifiedMetrics = new HashSet<>(); + + for (Metric metric : metrics) { + String metricName = metric.getName(); + Consumer assertion = assertions.get(metricName); + + if (assertion != null) { + assertion.accept(metric); + } else { + unverifiedMetrics.add(metricName); + } + } + + if (strictMode && !unverifiedMetrics.isEmpty()) { + fail("Metrics received but not verified because no assertion exists: " + unverifiedMetrics); + } + } + + private void verifyAllExpectedMetricsWereReceived(List metrics) { + Set receivedMetricNames = + metrics.stream().map(Metric::getName).collect(Collectors.toSet()); + Set assertionNames = new HashSet<>(assertions.keySet()); + + assertionNames.removeAll(receivedMetricNames); + if (!assertionNames.isEmpty()) { + fail( + "Metrics expected but not received: " + + assertionNames + + "\nReceived only: " + + receivedMetricNames); + } + } +} diff --git a/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/TargetSystemTest.java b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/TargetSystemTest.java new file mode 100644 index 0000000000..8dcee77386 --- /dev/null +++ b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/TargetSystemTest.java @@ -0,0 +1,299 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.jmx.rules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.awaitility.Awaitility.await; + +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.grpc.GrpcService; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; +import io.grpc.stub.StreamObserver; +import io.opentelemetry.instrumentation.jmx.yaml.JmxConfig; +import io.opentelemetry.instrumentation.jmx.yaml.JmxRule; +import io.opentelemetry.instrumentation.jmx.yaml.RuleParser; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceResponse; +import io.opentelemetry.proto.collector.metrics.v1.MetricsServiceGrpc; +import io.opentelemetry.proto.metrics.v1.Metric; +import io.opentelemetry.proto.metrics.v1.ResourceMetrics; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.MountableFile; + +/** Base class for testing YAML metric definitions with a real target system */ +public class TargetSystemTest { + + private static final Logger logger = LoggerFactory.getLogger(TargetSystemTest.class); + private static final Logger targetSystemLogger = LoggerFactory.getLogger("targetSystem"); + + private static final String AGENT_PATH = "/opentelemetry-instrumentation-javaagent.jar"; + + private static final Network network = Network.newNetwork(); + + private static OtlpGrpcServer otlpServer; + private static Path agentPath; + private static String otlpEndpoint; + + private GenericContainer targetSystem; + private Collection> targetDependencies; + + @BeforeAll + static void beforeAll() { + otlpServer = new OtlpGrpcServer(); + otlpServer.start(); + Testcontainers.exposeHostPorts(otlpServer.httpPort()); + otlpEndpoint = "http://host.testcontainers.internal:" + otlpServer.httpPort(); + + String path = System.getProperty("io.opentelemetry.javaagent.path"); + assertThat(path).isNotNull(); + agentPath = Paths.get(path); + assertThat(agentPath).isReadable().isNotEmptyFile(); + } + + @BeforeEach + void beforeEach() { + otlpServer.reset(); + } + + @AfterEach + void afterEach() { + stop(targetSystem); + targetSystem = null; + + if (targetDependencies != null) { + for (GenericContainer targetDependency : targetDependencies) { + stop(targetDependency); + } + } + targetDependencies = Collections.emptyList(); + } + + private static void stop(@Nullable GenericContainer container) { + if (container != null && container.isRunning()) { + container.stop(); + } + } + + @AfterAll + static void afterAll() { + try { + otlpServer.stop().get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + + protected static String javaAgentJvmArgument() { + return "-javaagent:" + AGENT_PATH; + } + + protected static List javaPropertiesToJvmArgs(Map config) { + return config.entrySet().stream() + .map(e -> String.format("-D%s=%s", e.getKey(), e.getValue())) + .collect(Collectors.toList()); + } + + /** + * Generates otel configuration for JMX testing with instrumentation agent + * + * @param yamlFiles JMX metrics definitions in YAML + * @return map of otel configuration properties for JMX testing + */ + protected static Map otelConfigProperties(List yamlFiles) { + Map config = new HashMap<>(); + // only export metrics + config.put("otel.logs.exporter", "none"); + config.put("otel.traces.exporter", "none"); + config.put("otel.metrics.exporter", "otlp"); + // use test grpc endpoint + config.put("otel.exporter.otlp.endpoint", otlpEndpoint); + config.put("otel.exporter.otlp.protocol", "grpc"); + // short export interval for testing + config.put("otel.metric.export.interval", "5s"); + // disable runtime telemetry metrics + config.put("otel.instrumentation.runtime-telemetry.enabled", "false"); + // set yaml config files to test + config.put("otel.jmx.target", "tomcat"); + config.put( + "otel.jmx.config", + yamlFiles.stream() + .map(TargetSystemTest::containerYamlPath) + .collect(Collectors.joining(","))); + return config; + } + + /** + * Starts the target system + * + * @param target target system to start + */ + protected void startTarget(GenericContainer target) { + startTarget(target, Collections.emptyList()); + } + + /** + * Starts the target system with its dependencies first + * + * @param target target system + * @param targetDependencies dependencies of target system + */ + protected void startTarget( + GenericContainer target, Collection> targetDependencies) { + + // If there are any containers that must be started before target then initialize them. + // Then make target depending on them, so it is started after dependencies + this.targetDependencies = targetDependencies; + for (GenericContainer container : targetDependencies) { + container.withNetwork(network); + target.dependsOn(container); + } + + targetSystem = + target.withLogConsumer(new Slf4jLogConsumer(targetSystemLogger)).withNetwork(network); + targetSystem.start(); + } + + protected static void copyFilesToTarget(GenericContainer target, List yamlFiles) { + // copy agent to target system + target.withCopyFileToContainer(MountableFile.forHostPath(agentPath), AGENT_PATH); + + // copy yaml files to target system + for (String file : yamlFiles) { + String resourcePath = yamlResourcePath(file); + String destPath = containerYamlPath(file); + logger.info("copying yaml from resources {} to container {}", resourcePath, destPath); + target.withCopyFileToContainer(MountableFile.forClasspathResource(resourcePath), destPath); + } + } + + private static String yamlResourcePath(String yaml) { + return "jmx/rules/" + yaml; + } + + private static String containerYamlPath(String yaml) { + return "/" + yaml; + } + + /** + * Validates YAML definition by parsing it to check for syntax errors + * + * @param yaml path to YAML resource (in classpath) + */ + protected void validateYamlSyntax(String yaml) { + String path = yamlResourcePath(yaml); + try (InputStream input = TargetSystemTest.class.getClassLoader().getResourceAsStream(path)) { + JmxConfig config; + // try-catch to provide a slightly better error + try { + config = RuleParser.get().loadConfig(input); + } catch (RuntimeException e) { + fail("Failed to parse yaml file " + path, e); + throw e; + } + + // make sure all the rules in that file are valid + for (JmxRule rule : config.getRules()) { + try { + rule.buildMetricDef(); + } catch (Exception e) { + fail("Failed to build metric definition " + rule.getBean(), e); + } + } + } catch (IOException e) { + fail("Failed to read yaml file " + path, e); + } + } + + protected void verifyMetrics(MetricsVerifier metricsVerifier) { + await() + .atMost(Duration.ofSeconds(60)) + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted( + () -> { + List receivedMetrics = otlpServer.getMetrics(); + assertThat(receivedMetrics).describedAs("No metric received").isNotEmpty(); + + List metrics = + receivedMetrics.stream() + .map(ExportMetricsServiceRequest::getResourceMetricsList) + .flatMap(rm -> rm.stream().map(ResourceMetrics::getScopeMetricsList)) + .flatMap(Collection::stream) + .filter( + // TODO: disabling batch span exporter might help remove unwanted metrics + sm -> sm.getScope().getName().equals("io.opentelemetry.jmx")) + .flatMap(sm -> sm.getMetricsList().stream()) + .collect(Collectors.toList()); + + assertThat(metrics).describedAs("Metrics received but not from JMX").isNotEmpty(); + + metricsVerifier.verify(metrics); + }); + } + + /** Minimal OTLP gRPC backend to capture metrics */ + private static class OtlpGrpcServer extends ServerExtension { + + private final BlockingQueue metricRequests = + new LinkedBlockingDeque<>(); + + List getMetrics() { + return new ArrayList<>(metricRequests); + } + + void reset() { + metricRequests.clear(); + } + + @Override + protected void configure(ServerBuilder sb) { + sb.service( + GrpcService.builder() + .addService( + new MetricsServiceGrpc.MetricsServiceImplBase() { + @Override + public void export( + ExportMetricsServiceRequest request, + StreamObserver responseObserver) { + + // verbose but helpful to diagnose what is received + logger.debug("receiving metrics {}", request); + + metricRequests.add(request); + responseObserver.onNext(ExportMetricsServiceResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + }) + .build()); + sb.http(0); + } + } +} diff --git a/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/AttributeMatcher.java b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/AttributeMatcher.java new file mode 100644 index 0000000000..33721e66cc --- /dev/null +++ b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/AttributeMatcher.java @@ -0,0 +1,61 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.jmx.rules.assertions; + +import javax.annotation.Nullable; + +/** Implements functionality of matching data point attributes. */ +public class AttributeMatcher { + private final String attributeName; + @Nullable private final String attributeValue; + + /** + * Create instance used to match data point attribute with any value. + * + * @param attributeName matched attribute name + */ + AttributeMatcher(String attributeName) { + this(attributeName, null); + } + + /** + * Create instance used to match data point attribute with te same name and with the same value. + * + * @param attributeName attribute name + * @param attributeValue attribute value + */ + AttributeMatcher(String attributeName, @Nullable String attributeValue) { + this.attributeName = attributeName; + this.attributeValue = attributeValue; + } + + /** + * Return name of data point attribute that this AttributeMatcher is supposed to match value with. + * + * @return name of validated attribute + */ + public String getAttributeName() { + return attributeName; + } + + @Override + public String toString() { + return attributeValue == null + ? '{' + attributeName + '}' + : '{' + attributeName + '=' + attributeValue + '}'; + } + + /** + * Verify if this matcher is matching provided attribute value. If this matcher holds null value + * then it is matching any attribute value. + * + * @param value a value to be matched + * @return true if this matcher is matching provided value, false otherwise. + */ + boolean matchesValue(String value) { + return attributeValue == null || attributeValue.equals(value); + } +} diff --git a/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/AttributeMatcherGroup.java b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/AttributeMatcherGroup.java new file mode 100644 index 0000000000..90e6a0da77 --- /dev/null +++ b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/AttributeMatcherGroup.java @@ -0,0 +1,60 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.jmx.rules.assertions; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +/** Group of attribute matchers */ +public class AttributeMatcherGroup { + + // stored as a Map for easy lookup by name + private final Map matchers; + + /** + * Constructor for a set of attribute matchers + * + * @param matchers collection of matchers to build a group from + * @throws IllegalStateException if there is any duplicate key + */ + AttributeMatcherGroup(Collection matchers) { + this.matchers = + matchers.stream().collect(Collectors.toMap(AttributeMatcher::getAttributeName, m -> m)); + } + + /** + * Checks if attributes match this attribute matcher group + * + * @param attributes attributes to check as map + * @return {@literal true} when the attributes match all attributes from this group + */ + public boolean matches(Map attributes) { + if (attributes.size() != matchers.size()) { + return false; + } + + for (Map.Entry entry : attributes.entrySet()) { + AttributeMatcher matcher = matchers.get(entry.getKey()); + if (matcher == null) { + // no matcher for this key: unexpected key + return false; + } + + if (!matcher.matchesValue(entry.getValue())) { + // value does not match: unexpected value + return false; + } + } + + return true; + } + + @Override + public String toString() { + return matchers.values().toString(); + } +} diff --git a/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/DataPointAttributes.java b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/DataPointAttributes.java new file mode 100644 index 0000000000..c72099e85e --- /dev/null +++ b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/DataPointAttributes.java @@ -0,0 +1,53 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.jmx.rules.assertions; + +import java.util.Arrays; + +/** + * Utility class implementing convenience static methods to construct data point attribute matchers + * and sets of matchers. + */ +public class DataPointAttributes { + private DataPointAttributes() {} + + /** + * Create instance of matcher that should be used to check if data point attribute with given name + * has value identical to the one provided as a parameter (exact match). + * + * @param name name of the data point attribute to check + * @param value expected value of checked data point attribute + * @return instance of matcher + */ + public static AttributeMatcher attribute(String name, String value) { + return new AttributeMatcher(name, value); + } + + /** + * Create instance of matcher that should be used to check if data point attribute with given name + * exists. Any value of the attribute is considered as matching (any value match). + * + * @param name name of the data point attribute to check + * @return instance of matcher + */ + public static AttributeMatcher attributeWithAnyValue(String name) { + return new AttributeMatcher(name); + } + + /** + * Creates a group of attribute matchers that should be used to verify data point attributes. + * + * @param attributes list of matchers to create group. It must contain matchers with unique names. + * @return group of attribute matchers + * @throws IllegalArgumentException if provided list contains two or more matchers with the same + * attribute name + * @see MetricAssert#hasDataPointsWithAttributes(AttributeMatcherGroup...) for detailed + * description off the algorithm used for matching + */ + public static AttributeMatcherGroup attributeGroup(AttributeMatcher... attributes) { + return new AttributeMatcherGroup(Arrays.asList(attributes)); + } +} diff --git a/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/JmxAssertj.java b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/JmxAssertj.java new file mode 100644 index 0000000000..45a2d0cd65 --- /dev/null +++ b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/JmxAssertj.java @@ -0,0 +1,18 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.jmx.rules.assertions; + +import io.opentelemetry.proto.metrics.v1.Metric; + +/** Dedicated Assertj extension to provide convenient fluent API for metrics testing */ +// TODO: we should contribute this back to sdk-testing +// This has been intentionally not named `*Assertions` to prevent checkstyle rule to be triggered +public class JmxAssertj extends org.assertj.core.api.Assertions { + + public static MetricAssert assertThat(Metric metric) { + return new MetricAssert(metric); + } +} diff --git a/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/MetricAssert.java b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/MetricAssert.java new file mode 100644 index 0000000000..2ead92ae76 --- /dev/null +++ b/instrumentation/jmx-metrics/library/src/test/java/io/opentelemetry/instrumentation/jmx/rules/assertions/MetricAssert.java @@ -0,0 +1,266 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.jmx.rules.assertions; + +import static io.opentelemetry.instrumentation.jmx.rules.assertions.DataPointAttributes.attributeGroup; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import io.opentelemetry.proto.metrics.v1.Metric; +import io.opentelemetry.proto.metrics.v1.NumberDataPoint; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.internal.Integers; +import org.assertj.core.internal.Iterables; +import org.assertj.core.internal.Objects; + +public class MetricAssert extends AbstractAssert { + + private static final Objects objects = Objects.instance(); + private static final Iterables iterables = Iterables.instance(); + private static final Integers integers = Integers.instance(); + + private boolean strict; + + private boolean descriptionChecked; + private boolean unitChecked; + private boolean typeChecked; + private boolean dataPointAttributesChecked; + + MetricAssert(Metric actual) { + super(actual, MetricAssert.class); + } + + public void setStrict(boolean strict) { + this.strict = strict; + } + + public void strictCheck() { + strictCheck("description", /* expectedCheckStatus= */ true, descriptionChecked); + strictCheck("unit", /* expectedCheckStatus= */ true, unitChecked); + strictCheck("type", /* expectedCheckStatus= */ true, typeChecked); + strictCheck( + "data point attributes", /* expectedCheckStatus= */ true, dataPointAttributesChecked); + } + + private void strictCheck( + String metricProperty, boolean expectedCheckStatus, boolean actualCheckStatus) { + if (!strict) { + return; + } + String failMsgPrefix = expectedCheckStatus ? "Missing" : "Duplicate"; + info.description( + "%s assertion on %s for metric '%s'", failMsgPrefix, metricProperty, actual.getName()); + objects.assertEqual(info, actualCheckStatus, expectedCheckStatus); + } + + /** + * Verifies metric description + * + * @param description expected description + * @return this + */ + @CanIgnoreReturnValue + public MetricAssert hasDescription(String description) { + isNotNull(); + + info.description("unexpected description for metric '%s'", actual.getName()); + objects.assertEqual(info, actual.getDescription(), description); + strictCheck("description", /* expectedCheckStatus= */ false, descriptionChecked); + descriptionChecked = true; + return this; + } + + /** + * Verifies metric unit + * + * @param unit expected unit + * @return this + */ + @CanIgnoreReturnValue + public MetricAssert hasUnit(String unit) { + isNotNull(); + + info.description("unexpected unit for metric '%s'", actual.getName()); + objects.assertEqual(info, actual.getUnit(), unit); + strictCheck("unit", /* expectedCheckStatus= */ false, unitChecked); + unitChecked = true; + return this; + } + + /** + * Verifies the metric is a gauge + * + * @return this + */ + @CanIgnoreReturnValue + public MetricAssert isGauge() { + isNotNull(); + + info.description("gauge expected for metric '%s'", actual.getName()); + objects.assertEqual(info, actual.hasGauge(), true); + strictCheck("type", /* expectedCheckStatus= */ false, typeChecked); + typeChecked = true; + return this; + } + + @CanIgnoreReturnValue + private MetricAssert hasSum(boolean monotonic) { + isNotNull(); + + info.description("sum expected for metric '%s'", actual.getName()); + objects.assertEqual(info, actual.hasSum(), true); + + String sumType = monotonic ? "monotonic" : "non-monotonic"; + info.description("sum for metric '%s' is expected to be %s", actual.getName(), sumType); + objects.assertEqual(info, actual.getSum().getIsMonotonic(), monotonic); + return this; + } + + /** + * Verifies the metric is a counter + * + * @return this + */ + @CanIgnoreReturnValue + public MetricAssert isCounter() { + // counters have a monotonic sum as their value can't decrease + hasSum(true); + strictCheck("type", /* expectedCheckStatus= */ false, typeChecked); + typeChecked = true; + return this; + } + + /** + * Verifies the metric is an up-down counter + * + * @return this + */ + @CanIgnoreReturnValue + public MetricAssert isUpDownCounter() { + // up down counters are non-monotonic as their value can increase & decrease + hasSum(false); + strictCheck("type", /* expectedCheckStatus= */ false, typeChecked); + typeChecked = true; + return this; + } + + /** + * Verifies that there is no attribute in any of data points. + * + * @return this + */ + @CanIgnoreReturnValue + public MetricAssert hasDataPointsWithoutAttributes() { + isNotNull(); + + return checkDataPoints( + dataPoints -> { + dataPointsCommonCheck(dataPoints); + + // all data points must not have any attribute + for (NumberDataPoint dataPoint : dataPoints) { + info.description( + "no attribute expected on data point for metric '%s'", actual.getName()); + iterables.assertEmpty(info, dataPoint.getAttributesList()); + } + }); + } + + @CanIgnoreReturnValue + private MetricAssert checkDataPoints(Consumer> listConsumer) { + // in practice usually one set of data points is provided but the + // protobuf does not enforce that, so we have to ensure checking at least one + int count = 0; + if (actual.hasGauge()) { + count++; + listConsumer.accept(actual.getGauge().getDataPointsList()); + } + if (actual.hasSum()) { + count++; + listConsumer.accept(actual.getSum().getDataPointsList()); + } + info.description("at least one set of data points expected for metric '%s'", actual.getName()); + integers.assertGreaterThan(info, count, 0); + + strictCheck( + "data point attributes", /* expectedCheckStatus= */ false, dataPointAttributesChecked); + dataPointAttributesChecked = true; + return this; + } + + private void dataPointsCommonCheck(List dataPoints) { + info.description("unable to retrieve data points from metric '%s'", actual.getName()); + objects.assertNotNull(info, dataPoints); + + // at least one data point must be reported + info.description("at least one data point expected for metric '%s'", actual.getName()); + iterables.assertNotEmpty(info, dataPoints); + } + + /** + * Verifies that all metric data points have the same expected one attribute + * + * @param expectedAttribute attribute matcher to validate data points attributes + * @return this + */ + @CanIgnoreReturnValue + public final MetricAssert hasDataPointsWithOneAttribute(AttributeMatcher expectedAttribute) { + return hasDataPointsWithAttributes(attributeGroup(expectedAttribute)); + } + + /** + * Verifies that every data point attributes is matched exactly by one of the matcher groups + * provided. Also, each matcher group must match at least one data point attributes set. Data + * point attributes are matched by matcher group if each attribute is matched by one matcher and + * each matcher matches one attribute. In other words: number of attributes is the same as number + * of matchers and there is 1:1 matching between them. + * + * @param matcherGroups array of attribute matcher groups + * @return this + */ + @CanIgnoreReturnValue + public final MetricAssert hasDataPointsWithAttributes(AttributeMatcherGroup... matcherGroups) { + return checkDataPoints( + dataPoints -> { + dataPointsCommonCheck(dataPoints); + + boolean[] matchedSets = new boolean[matcherGroups.length]; + + // validate each datapoint attributes match exactly one of the provided attributes sets + for (NumberDataPoint dataPoint : dataPoints) { + Map dataPointAttributes = + dataPoint.getAttributesList().stream() + .collect( + Collectors.toMap(KeyValue::getKey, kv -> kv.getValue().getStringValue())); + int matchCount = 0; + for (int i = 0; i < matcherGroups.length; i++) { + if (matcherGroups[i].matches(dataPointAttributes)) { + matchedSets[i] = true; + matchCount++; + } + } + + info.description( + "data point attributes '%s' for metric '%s' must match exactly one of the attribute sets '%s'.\nActual data points: %s", + dataPointAttributes, actual.getName(), Arrays.asList(matcherGroups), dataPoints); + integers.assertEqual(info, matchCount, 1); + } + + // check that all attribute sets matched at least once + for (int i = 0; i < matchedSets.length; i++) { + info.description( + "no data point matched attribute set '%s' for metric '%s'", + matcherGroups[i], actual.getName()); + objects.assertEqual(info, matchedSets[i], true); + } + }); + } +}