yaml jmx metrics test infrastructure (#13597)
This commit is contained in:
parent
d503937806
commit
fd8fe9050c
|
@ -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.
|
||||
|
|
|
@ -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()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue