yaml jmx metrics test infrastructure (#13597)

This commit is contained in:
SylvainJuge 2025-04-09 18:52:40 +02:00 committed by GitHub
parent d503937806
commit fd8fe9050c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 908 additions and 1 deletions

View File

@ -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.

View File

@ -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>("shadowJar").get()
dependsOn(shadowTask)
inputs.files(layout.files(shadowTask))
.withPropertyName("javaagent")
.withNormalizer(ClasspathNormalizer::class)
doFirst {
jvmArgs("-Dio.opentelemetry.javaagent.path=${shadowTask.archiveFile.get()}")
}
}
}

View File

@ -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<String, Consumer<Metric>> 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<MetricAssert> 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<Metric> metrics) {
verifyAllExpectedMetricsWereReceived(metrics);
Set<String> unverifiedMetrics = new HashSet<>();
for (Metric metric : metrics) {
String metricName = metric.getName();
Consumer<Metric> 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<Metric> metrics) {
Set<String> receivedMetricNames =
metrics.stream().map(Metric::getName).collect(Collectors.toSet());
Set<String> assertionNames = new HashSet<>(assertions.keySet());
assertionNames.removeAll(receivedMetricNames);
if (!assertionNames.isEmpty()) {
fail(
"Metrics expected but not received: "
+ assertionNames
+ "\nReceived only: "
+ receivedMetricNames);
}
}
}

View File

@ -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<GenericContainer<?>> 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<String> javaPropertiesToJvmArgs(Map<String, String> 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<String, String> otelConfigProperties(List<String> yamlFiles) {
Map<String, String> 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<GenericContainer<?>> 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<String> 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<ExportMetricsServiceRequest> receivedMetrics = otlpServer.getMetrics();
assertThat(receivedMetrics).describedAs("No metric received").isNotEmpty();
List<Metric> 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<ExportMetricsServiceRequest> metricRequests =
new LinkedBlockingDeque<>();
List<ExportMetricsServiceRequest> 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<ExportMetricsServiceResponse> 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);
}
}
}

View File

@ -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);
}
}

View File

@ -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<String, AttributeMatcher> 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<AttributeMatcher> 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<String, String> attributes) {
if (attributes.size() != matchers.size()) {
return false;
}
for (Map.Entry<String, String> 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();
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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<MetricAssert, Metric> {
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<List<NumberDataPoint>> 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<NumberDataPoint> 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<String, String> 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);
}
});
}
}