diff --git a/bom/application/pom.xml b/bom/application/pom.xml index c5fd6ad9c5a395..0bbfa1fba4150b 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -226,6 +226,8 @@ 1.0.0 3.0.0 2.12.3 + + 0.16.0 1.0.10 @@ -463,6 +465,19 @@ import + + + io.prometheus + simpleclient + ${prometheus.version} + + + io.prometheus + simpleclient_common + ${prometheus.version} + + + @@ -2944,6 +2959,66 @@ quarkus-virtual-threads-deployment ${project.version} + + io.quarkus + quarkus-observability-common + ${project.version} + + + io.quarkus + quarkus-observability + ${project.version} + + + io.quarkus + quarkus-observability-promql + ${project.version} + + + io.quarkus + quarkus-observability-victoriametrics + ${project.version} + + + io.quarkus + quarkus-observability-testlibs + ${project.version} + + + io.quarkus + quarkus-observability-testcontainers + ${project.version} + + + io.quarkus + quarkus-observability-devresource + ${project.version} + + + io.quarkus + quarkus-observability-devresource-victoriametrics + ${project.version} + + + io.quarkus + quarkus-observability-devresource-vmagent + ${project.version} + + + io.quarkus + quarkus-observability-devresource-grafana + ${project.version} + + + io.quarkus + quarkus-observability-devresource-otel-collector + ${project.version} + + + io.quarkus + quarkus-observability-devresource-jaeger + ${project.version} + diff --git a/core/deployment/src/main/java/io/quarkus/deployment/Feature.java b/core/deployment/src/main/java/io/quarkus/deployment/Feature.java index cebff1c71facec..8f431c846cdf6c 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/Feature.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/Feature.java @@ -67,6 +67,7 @@ public enum Feature { NARAYANA_LRA, NARAYANA_STM, NEO4J, + OBSERVABILITY, OIDC, OIDC_CLIENT, OIDC_CLIENT_FILTER, diff --git a/core/runtime/src/main/java/io/quarkus/runtime/util/EnumerationUtil.java b/core/runtime/src/main/java/io/quarkus/runtime/util/EnumerationUtil.java new file mode 100644 index 00000000000000..6dbab086bb060a --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/util/EnumerationUtil.java @@ -0,0 +1,69 @@ +package io.quarkus.runtime.util; + +import java.util.Enumeration; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Spliterator; +import java.util.function.Consumer; +import java.util.stream.Stream; + +/** + * Transform to "old school" Enumeration from Iterator/Spliterator/Stream + */ +public class EnumerationUtil { + public static Enumeration from(Iterator iterator) { + Objects.requireNonNull(iterator); + + return new Enumeration() { + @Override + public boolean hasMoreElements() { + return iterator.hasNext(); + } + + @Override + public T nextElement() { + return iterator.next(); + } + }; + } + + public static Enumeration from(Spliterator spliterator) { + Objects.requireNonNull(spliterator); + + class Adapter implements Enumeration, Consumer { + boolean valueReady; + T nextElement; + + public void accept(T t) { + this.valueReady = true; + this.nextElement = t; + } + + public boolean hasMoreElements() { + if (!this.valueReady) { + spliterator.tryAdvance(this); + } + + return this.valueReady; + } + + public T nextElement() { + if (!this.valueReady && !this.hasMoreElements()) { + throw new NoSuchElementException(); + } else { + this.valueReady = false; + T t = this.nextElement; + this.nextElement = null; + return t; + } + } + } + + return new Adapter(); + } + + public static Enumeration from(Stream stream) { + return from(stream.spliterator()); + } +} diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index cbdbc050e4dfc4..b3f2c6bf22c49f 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -1526,6 +1526,19 @@ + + io.quarkus + quarkus-observability + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-oidc diff --git a/docs/pom.xml b/docs/pom.xml index dbf502dbfae576..5f81a1e1d56f42 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -1542,6 +1542,19 @@ + + io.quarkus + quarkus-observability-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-oidc-deployment diff --git a/extensions/devservices/common/src/main/java/io/quarkus/devservices/common/ContainerLocator.java b/extensions/devservices/common/src/main/java/io/quarkus/devservices/common/ContainerLocator.java index 4c7835827fb47c..a9086956531364 100644 --- a/extensions/devservices/common/src/main/java/io/quarkus/devservices/common/ContainerLocator.java +++ b/extensions/devservices/common/src/main/java/io/quarkus/devservices/common/ContainerLocator.java @@ -1,7 +1,9 @@ package io.quarkus.devservices.common; import java.util.Arrays; +import java.util.Objects; import java.util.Optional; +import java.util.function.BiConsumer; import java.util.function.BiPredicate; import org.jboss.logging.Logger; @@ -61,6 +63,30 @@ public Optional locateContainer(String serviceName, boolean sh } } + /** + * @return container id, if exists + */ + public Optional locateContainer(String serviceName, boolean shared, LaunchMode launchMode, + BiConsumer consumer) { + if (shared && launchMode == LaunchMode.DEVELOPMENT) { + return lookup(serviceName) + .map(container -> { + Arrays.stream(container.getPorts()) + .filter(cp -> Objects.nonNull(cp.getPublicPort()) && Objects.nonNull(cp.getPrivatePort())) + .forEach(cp -> { + ContainerAddress containerAddress = new ContainerAddress( + container.getId(), + DockerClientFactory.instance().dockerHostIpAddress(), + cp.getPublicPort()); + consumer.accept(cp.getPrivatePort(), containerAddress); + }); + return container.getId(); + }); + } else { + return Optional.empty(); + } + } + public Optional locatePublicPort(String serviceName, boolean shared, LaunchMode launchMode, int privatePort) { if (shared && launchMode == LaunchMode.DEVELOPMENT) { return lookup(serviceName) diff --git a/extensions/devservices/common/src/main/java/io/quarkus/devservices/common/ContainerShutdownCloseable.java b/extensions/devservices/common/src/main/java/io/quarkus/devservices/common/ContainerShutdownCloseable.java index 60b1b8636dcedb..0bfcc741998d8e 100644 --- a/extensions/devservices/common/src/main/java/io/quarkus/devservices/common/ContainerShutdownCloseable.java +++ b/extensions/devservices/common/src/main/java/io/quarkus/devservices/common/ContainerShutdownCloseable.java @@ -21,14 +21,14 @@ public final class ContainerShutdownCloseable implements Closeable { private static final Logger LOG = Logger.getLogger(ContainerShutdownCloseable.class); - private final GenericContainer container; + private final GenericContainer container; private final String friendlyServiceName; /** * @param container the container to be eventually closed * @param friendlyServiceName for logging purposes */ - public ContainerShutdownCloseable(GenericContainer container, String friendlyServiceName) { + public ContainerShutdownCloseable(GenericContainer container, String friendlyServiceName) { Objects.requireNonNull(container); Objects.requireNonNull(friendlyServiceName); this.container = container; diff --git a/extensions/observability/common/pom.xml b/extensions/observability/common/pom.xml new file mode 100644 index 00000000000000..02a72921970dc2 --- /dev/null +++ b/extensions/observability/common/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + quarkus-observability-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-common + Quarkus - Observability - Common + + + + io.quarkus + quarkus-core + provided + + + io.smallrye.config + smallrye-config-core + provided + + + diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/ContainerConstants.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/ContainerConstants.java new file mode 100644 index 00000000000000..95108d38b6fcb2 --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/ContainerConstants.java @@ -0,0 +1,10 @@ +package io.quarkus.observability.common; + +public final class ContainerConstants { + + public static final String GRAFANA = "grafana/grafana:10.1.0"; + public static final String JAEGER = "quay.io/jaegertracing/all-in-one:1.48.0"; + public static final String OTEL = "otel/opentelemetry-collector-contrib:0.83.0"; + public static final String VICTORIA_METRICS = "victoriametrics/victoria-metrics:v1.93.0"; + public static final String VM_AGENT = "victoriametrics/vmagent:v1.93.0"; +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/AbstractContainerConfig.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/AbstractContainerConfig.java new file mode 100644 index 00000000000000..3ddefb22929946 --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/AbstractContainerConfig.java @@ -0,0 +1,51 @@ +package io.quarkus.observability.common.config; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +public abstract class AbstractContainerConfig implements ContainerConfig { + + private final String imageName; + private final boolean shared; + + public AbstractContainerConfig(String imageName) { + this(imageName, true); + } + + public AbstractContainerConfig(String imageName, boolean shared) { + this.imageName = imageName; + this.shared = shared; + } + + @Override + public boolean enabled() { + return true; + } + + @Override + public String imageName() { + return imageName; + } + + @Override + public boolean shared() { + return shared; + } + + @Override + public Optional> networkAliases() { + return Optional.empty(); + } + + @Override + public String label() { + String sn = getClass().getSimpleName().toLowerCase(Locale.ROOT); + return "quarkus-dev-resource-" + sn; + } + + @Override + public String serviceName() { + return "quarkus"; + } +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ConfigUtils.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ConfigUtils.java new file mode 100644 index 00000000000000..627cdcdd01d8ba --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ConfigUtils.java @@ -0,0 +1,26 @@ +package io.quarkus.observability.common.config; + +public class ConfigUtils { + + public static boolean isEnabled(ContainerConfig config) { + if (config != null && config.enabled()) { + DevTarget target = config.getClass().getAnnotation(DevTarget.class); + if (target != null) { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + try { + cl.loadClass(target.value()); + return true; + } catch (ClassNotFoundException ignore) { + } + } + } + return false; + } + + public static String vmEndpoint(VictoriaMetricsConfig vmc) { + String host = vmc.networkAliases().map(s -> s.iterator().next()).orElse("victoria-metrics"); + int port = vmc.port(); + return String.format("%s:%s", host, port); + } + +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ContainerConfig.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ContainerConfig.java new file mode 100644 index 00000000000000..d6bb59e981d58f --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ContainerConfig.java @@ -0,0 +1,69 @@ +package io.quarkus.observability.common.config; + +import java.util.Optional; +import java.util.Set; + +import io.smallrye.config.WithDefault; + +public interface ContainerConfig { + + /** + * If DevServices has been explicitly enabled or disabled. DevServices is generally enabled + * by default, unless there is an existing configuration present. + *

+ * When DevServices is enabled Quarkus will attempt to automatically configure and start + * a containers when running in Dev or Test mode and when Docker is running. + */ + @WithDefault("true") + boolean enabled(); + + /** + * The container image name to use, for container based DevServices providers. + */ + String imageName(); + + /** + * Indicates if the container managed by Quarkus Dev Services is shared. + * When shared, Quarkus looks for running containers using label-based service discovery. + * If a matching container is found, it is used, and so a second one is not started. + * Otherwise, Dev Services starts a new container. + *

+ * The discovery uses the {@code quarkus-dev-service-label} label. + * The value is configured using the {@code service-name} property. + *

+ * Container sharing is only used in dev mode. + */ + @WithDefault("true") + boolean shared(); + + /** + * Network aliases. + * + * @return metwork aliases + */ + Optional> networkAliases(); + + /** + * The full name of the label attached to the started container. + * This label is used when {@code shared} is set to {@code true}. + * In this case, before starting a container, Dev Services for looks for a container with th label + * set to the configured value. If found, it will use this container instead of starting a new one. Otherwise, it + * starts a new container with this label set to the specified value. + *

+ * This property is used when you need multiple shared containers. + */ + String label(); + + /** + * The value of the {@code quarkus-dev-service} label attached to the started container. + * This property is used when {@code shared} is set to {@code true}. + * In this case, before starting a container, Dev Services for looks for a container with the + * {@code quarkus-dev-service} label + * set to the configured value. If found, it will use this container instead of starting a new one. Otherwise, it + * starts a new container with the {@code quarkus-dev-service} label set to the specified value. + *

+ * This property is used when you need multiple shared containers. + */ + @WithDefault("quarkus") + String serviceName(); +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/DevTarget.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/DevTarget.java new file mode 100644 index 00000000000000..001da8a94f44d1 --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/DevTarget.java @@ -0,0 +1,16 @@ +package io.quarkus.observability.common.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface DevTarget { + /** + * The dev resource we require on the classpath, + * for this config to fully kick-in. + */ + String value(); +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/GrafanaConfig.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/GrafanaConfig.java new file mode 100644 index 00000000000000..4b5a97d6bf9dcf --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/GrafanaConfig.java @@ -0,0 +1,23 @@ +package io.quarkus.observability.common.config; + +import java.util.Optional; +import java.util.Set; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.runtime.annotations.ConfigGroup; +import io.smallrye.config.WithDefault; + +@ConfigGroup +public interface GrafanaConfig extends ContainerConfig { + @WithDefault(ContainerConstants.GRAFANA) + String imageName(); + + @WithDefault("grafana,grafana.testcontainer.docker") + Optional> networkAliases(); + + @WithDefault("quarkus-dev-service-grafana") + String label(); + + @WithDefault("datasources.yaml") + String datasourcesFile(); +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/JaegerConfig.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/JaegerConfig.java new file mode 100644 index 00000000000000..7407d3ba9a3cef --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/JaegerConfig.java @@ -0,0 +1,21 @@ +package io.quarkus.observability.common.config; + +import java.util.Optional; +import java.util.Set; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.runtime.annotations.ConfigGroup; +import io.smallrye.config.WithDefault; + +@ConfigGroup +@DevTarget("io.quarkus.observability.devresource.jaeger.JaegerResource") +public interface JaegerConfig extends ContainerConfig { + @WithDefault(ContainerConstants.JAEGER) + String imageName(); + + @WithDefault("jaeger") + Optional> networkAliases(); + + @WithDefault("quarkus-dev-service-jaeger") + String label(); +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ModulesConfiguration.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ModulesConfiguration.java new file mode 100644 index 00000000000000..a968f4ce47b1d1 --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/ModulesConfiguration.java @@ -0,0 +1,20 @@ +package io.quarkus.observability.common.config; + +import io.quarkus.runtime.annotations.ConfigDocSection; + +public interface ModulesConfiguration { + @ConfigDocSection + GrafanaConfig grafana(); + + @ConfigDocSection + JaegerConfig jaeger(); + + @ConfigDocSection + OTelConfig otel(); + + @ConfigDocSection + VictoriaMetricsConfig victoriaMetrics(); + + @ConfigDocSection + VMAgentConfig vmAgent(); +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/OTelConfig.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/OTelConfig.java new file mode 100644 index 00000000000000..99a831193b2978 --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/OTelConfig.java @@ -0,0 +1,22 @@ +package io.quarkus.observability.common.config; + +import java.util.Optional; +import java.util.Set; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.runtime.annotations.ConfigGroup; +import io.smallrye.config.WithDefault; + +@ConfigGroup +public interface OTelConfig extends ContainerConfig { + @WithDefault(ContainerConstants.OTEL) + String imageName(); + + @WithDefault("otel-collector") + Optional> networkAliases(); + + @WithDefault("quarkus-dev-service-otel") + String label(); + + Optional configFile(); +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/VMAgentConfig.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/VMAgentConfig.java new file mode 100644 index 00000000000000..9867c237861374 --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/VMAgentConfig.java @@ -0,0 +1,18 @@ +package io.quarkus.observability.common.config; + +import java.util.OptionalInt; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.runtime.annotations.ConfigGroup; +import io.smallrye.config.WithDefault; + +@ConfigGroup +public interface VMAgentConfig extends ContainerConfig { + @WithDefault(ContainerConstants.VM_AGENT) + String imageName(); + + @WithDefault("quarkus-dev-service-vm-agent") + String label(); + + OptionalInt scrapePort(); +} diff --git a/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/VictoriaMetricsConfig.java b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/VictoriaMetricsConfig.java new file mode 100644 index 00000000000000..b35b5666897677 --- /dev/null +++ b/extensions/observability/common/src/main/java/io/quarkus/observability/common/config/VictoriaMetricsConfig.java @@ -0,0 +1,24 @@ +package io.quarkus.observability.common.config; + +import java.util.Optional; +import java.util.Set; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.runtime.annotations.ConfigGroup; +import io.smallrye.config.WithDefault; + +@ConfigGroup +@DevTarget("io.quarkus.observability.devresource.victoriametrics.VictoriaMetricsResource") +public interface VictoriaMetricsConfig extends ContainerConfig { + @WithDefault(ContainerConstants.VICTORIA_METRICS) + String imageName(); + + @WithDefault("victoria-metrics") + Optional> networkAliases(); + + @WithDefault("8428") + int port(); + + @WithDefault("quarkus-dev-service-victoria-metrics") + String label(); +} diff --git a/extensions/observability/deployment/pom.xml b/extensions/observability/deployment/pom.xml new file mode 100644 index 00000000000000..db6fa33639f910 --- /dev/null +++ b/extensions/observability/deployment/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + quarkus-observability-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-deployment + Quarkus - Observability - Deployment + + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-devservices-common + + + io.quarkus + quarkus-devtools-utilities + + + io.quarkus + quarkus-kubernetes-spi + + + io.quarkus + quarkus-observability + + + + io.quarkus + quarkus-junit5-internal + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + io.rest-assured + rest-assured + test + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesBuildItem.java b/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesBuildItem.java new file mode 100644 index 00000000000000..4bde7af481a59b --- /dev/null +++ b/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesBuildItem.java @@ -0,0 +1,6 @@ +package io.quarkus.observability.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; + +final class DevResourcesBuildItem extends SimpleBuildItem { +} diff --git a/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java b/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java new file mode 100644 index 00000000000000..88d17c7c34d13b --- /dev/null +++ b/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java @@ -0,0 +1,51 @@ +package io.quarkus.observability.deployment; + +import java.util.function.BooleanSupplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.quarkus.deployment.IsNormal; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; +import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.observability.runtime.DevResourceShutdownRecorder; +import io.quarkus.observability.runtime.DevResourcesConfigBuilder; +import io.quarkus.observability.runtime.config.ObservabilityConfiguration; + +@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = DevResourcesProcessor.IsEnabled.class) +class DevResourcesProcessor { + private static final Logger log = LoggerFactory.getLogger(DevResourcesProcessor.class); + private static final String FEATURE = "devresources"; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + @BuildStep + public RunTimeConfigBuilderBuildItem registerDevResourcesConfigSource() { + log.info("Adding dev resources config builder"); + return new RunTimeConfigBuilderBuildItem(DevResourcesConfigBuilder.class); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + public DevResourcesBuildItem shutdownDevResources(DevResourceShutdownRecorder recorder, ShutdownContextBuildItem shutdown) { + recorder.shutdown(shutdown); + return new DevResourcesBuildItem(); + } + + public static class IsEnabled implements BooleanSupplier { + ObservabilityConfiguration config; + + public boolean getAsBoolean() { + return config.devResources() && !config.enabled(); + } + } + +} diff --git a/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/ObservabilityProcessor.java b/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/ObservabilityProcessor.java new file mode 100644 index 00000000000000..e12f5133c89cc8 --- /dev/null +++ b/extensions/observability/deployment/src/main/java/io/quarkus/observability/deployment/ObservabilityProcessor.java @@ -0,0 +1,211 @@ +package io.quarkus.observability.deployment; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.jboss.logging.Logger; +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.deployment.Feature; +import io.quarkus.deployment.IsNormal; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem; +import io.quarkus.deployment.builditem.DockerStatusBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.LaunchModeBuildItem; +import io.quarkus.deployment.console.ConsoleInstalledBuildItem; +import io.quarkus.deployment.console.StartupLogCompressor; +import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig; +import io.quarkus.deployment.logging.LoggingSetupBuildItem; +import io.quarkus.devservices.common.ContainerLocator; +import io.quarkus.devservices.common.ContainerShutdownCloseable; +import io.quarkus.observability.common.config.ContainerConfig; +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.observability.devresource.DevResourceLifecycleManager; +import io.quarkus.observability.devresource.DevResources; +import io.quarkus.observability.runtime.config.ObservabilityConfiguration; +import io.quarkus.runtime.LaunchMode; + +@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = { GlobalDevServicesConfig.Enabled.class, + ObservabilityProcessor.IsEnabled.class }) +class ObservabilityProcessor { + private static final Logger log = Logger.getLogger(ObservabilityProcessor.class); + + private static final Map devServices = new ConcurrentHashMap<>(); + private static final Map capturedDevServicesConfigurations = new ConcurrentHashMap<>(); + private static final Map firstStart = new ConcurrentHashMap<>(); + + public static class IsEnabled implements BooleanSupplier { + ObservabilityConfiguration config; + + public boolean getAsBoolean() { + return config.enabled() && !config.devResources(); + } + } + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(Feature.OBSERVABILITY); + } + + private String devId(DevResourceLifecycleManager dev) { + String sn = dev.getClass().getSimpleName(); + int p = sn.indexOf("Resource"); + return sn.substring(0, p != -1 ? p : sn.length()); + } + + @BuildStep + public void startContainers(LaunchModeBuildItem launchMode, + DockerStatusBuildItem dockerStatusBuildItem, + ObservabilityConfiguration configuration, + Optional consoleInstalledBuildItem, + CuratedApplicationShutdownBuildItem closeBuildItem, + LoggingSetupBuildItem loggingSetupBuildItem, + GlobalDevServicesConfig devServicesConfig, + BuildProducer services) { + + if (!configuration.enabled()) { + log.infof("Observability dev services are disabled in config"); + return; + } + + if (!dockerStatusBuildItem.isDockerAvailable()) { + log.warn("Please get a working Docker instance"); + return; + } + + @SuppressWarnings("rawtypes") + List resources = DevResources.resources(); + // this should throw an exception on a duplicate + //noinspection ResultOfMethodCallIgnored + resources.stream().collect(Collectors.toMap(this::devId, Function.identity())); + + @SuppressWarnings("rawtypes") + Stream stream = resources.stream(); + if (configuration.parallel()) { + stream = stream.parallel(); + } + + stream.forEach(dev -> { + String devId = devId(dev); + + DevServicesResultBuildItem.RunningDevService devService = devServices.remove(devId); + ContainerConfig currentDevServicesConfiguration = dev.config(configuration); + + if (devService != null) { + ContainerConfig capturedDevServicesConfiguration = capturedDevServicesConfigurations.remove(devId); + boolean restartRequired = !currentDevServicesConfiguration.equals(capturedDevServicesConfiguration); + if (!restartRequired) { + services.produce(devService.toBuildItem()); + return; + } + try { + devService.close(); + } catch (Throwable e) { + log.errorf("Failed to stop %s container", devId, e); + } + } + + capturedDevServicesConfigurations.put(devId, currentDevServicesConfiguration); + + StartupLogCompressor compressor = new StartupLogCompressor( + (launchMode.isTest() ? "(test) " : "") + devId + " Dev Services Starting:", + consoleInstalledBuildItem, + loggingSetupBuildItem); + try { + DevServicesResultBuildItem.RunningDevService newDevService = startContainer( + devId, + dev, + currentDevServicesConfiguration, + configuration, + devServicesConfig.timeout); + if (newDevService == null) { + compressor.closeAndDumpCaptured(); + return; + } else { + compressor.close(); + } + + devService = newDevService; + devServices.put(devId, newDevService); + } catch (Throwable t) { + compressor.closeAndDumpCaptured(); + throw new RuntimeException(t); + } + + if (firstStart.computeIfAbsent(devId, x -> true)) { + Runnable closeTask = () -> { + DevServicesResultBuildItem.RunningDevService current = devServices.get(devId); + if (current != null) { + try { + current.close(); + } catch (Throwable t) { + log.errorf("Failed to stop %s container", devId, t); + } + } + firstStart.remove(devId); + //noinspection resource + devServices.remove(devId); + capturedDevServicesConfigurations.remove(devId); + }; + closeBuildItem.addCloseTask(closeTask, true); + } + + services.produce(devService.toBuildItem()); + }); + } + + private DevServicesResultBuildItem.RunningDevService startContainer( + String devId, + DevResourceLifecycleManager dev, + ContainerConfig capturedDevServicesConfiguration, + ModulesConfiguration root, + Optional timeout) { + + if (!capturedDevServicesConfiguration.enabled()) { + // explicitly disabled + log.debugf("Not starting Dev Services for %s as it has been disabled in the config", devId); + return null; + } + + if (!dev.enable()) { + return null; + } + + final Supplier defaultContainerSupplier = () -> { + GenericContainer container = dev.container(capturedDevServicesConfiguration, root); + timeout.ifPresent(container::withStartupTimeout); + Map config = dev.start(); + log.infof("Dev Service %s started, config: %s", devId, config); + return new DevServicesResultBuildItem.RunningDevService( + Feature.OBSERVABILITY.getName(), container.getContainerId(), + new ContainerShutdownCloseable(container, capturedDevServicesConfiguration.serviceName()), config); + }; + + Map config = new LinkedHashMap<>(); // old config + ContainerLocator containerLocator = new ContainerLocator(capturedDevServicesConfiguration.label(), 0); // can be 0, as we don't use it + return containerLocator + .locateContainer( + capturedDevServicesConfiguration.serviceName(), capturedDevServicesConfiguration.shared(), + LaunchMode.current(), (p, ca) -> config.putAll(dev.config(p, ca.getHost(), ca.getPort()))) + .map(cid -> { + log.infof("Dev Service %s re-used, config: %s", devId, config); + return new DevServicesResultBuildItem.RunningDevService(Feature.OBSERVABILITY.getName(), cid, + null, config); + }) + .orElseGet(defaultContainerSupplier); + } + +} diff --git a/extensions/observability/pom.xml b/extensions/observability/pom.xml new file mode 100644 index 00000000000000..5c277034ce3b65 --- /dev/null +++ b/extensions/observability/pom.xml @@ -0,0 +1,25 @@ + + + + quarkus-extensions-parent + io.quarkus + 999-SNAPSHOT + ../pom.xml + + 4.0.0 + + quarkus-observability-parent + Quarkus - Observability parent + pom + + common + promql + victoriametrics + testcontainers + testlibs + deployment + runtime + + \ No newline at end of file diff --git a/extensions/observability/promql/pom.xml b/extensions/observability/promql/pom.xml new file mode 100644 index 00000000000000..bc2bb2bdabeee0 --- /dev/null +++ b/extensions/observability/promql/pom.xml @@ -0,0 +1,102 @@ + + + 4.0.0 + + quarkus-observability-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-promql + Quarkus - Observability - PromQL client + Prometheus query language client + + + + + org.apache.maven.plugins + maven-compiler-plugin + + true + + + + + + + + + org.jboss.logging + jboss-logging + + + + com.fasterxml.jackson.core + jackson-annotations + + + + com.fasterxml.jackson.core + jackson-databind + + + + com.fasterxml.jackson.module + jackson-module-parameter-names + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + + + + jakarta.ws.rs + jakarta.ws.rs-api + + + + org.eclipse.microprofile.rest.client + microprofile-rest-client-api + + + + io.quarkus + quarkus-rest-client-reactive + + + + io.quarkus + quarkus-rest-client-reactive-jackson + + + + + + io.quarkus + quarkus-arc + test + + + + io.quarkus + quarkus-junit5 + test + + + + org.jboss.logmanager + jboss-logmanager-embedded + test + + + + + diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/PromQLService.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/PromQLService.java new file mode 100644 index 00000000000000..0db45420560510 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/PromQLService.java @@ -0,0 +1,117 @@ +package io.quarkus.observability.promql.client; + +import static io.quarkus.observability.promql.client.rest.InstantFormat.Kind.EPOCH_SECONDS; + +import java.time.Instant; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; + +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import io.quarkus.observability.promql.client.data.Dur; +import io.quarkus.observability.promql.client.data.LabelsResponse; +import io.quarkus.observability.promql.client.data.QueryResponse; +import io.quarkus.observability.promql.client.data.SeriesResponse; +import io.quarkus.observability.promql.client.rest.InstantFormat; +import io.quarkus.observability.promql.client.rest.RequestDebugFilter; +import io.quarkus.observability.promql.client.rest.ResponseDebugFilter; + +/** + * You can URL-encode these parameters directly in the request body + * by using the POST method and Content-Type: application/x-www-form-urlencoded header. + * This is useful when specifying a large query that may breach server-side URL character limits. + */ +@SuppressWarnings("RestParamTypeInspection") +@RegisterRestClient(configKey = "promql") +@RegisterProvider(RequestDebugFilter.class) +@RegisterProvider(ResponseDebugFilter.class) +public interface PromQLService { + + @GET + @Path("/api/v1/query") + QueryResponse getInstantQuery( + @QueryParam("query") String query, + @QueryParam("time") @InstantFormat(EPOCH_SECONDS) Instant time, + @QueryParam("timeout") Dur timeout); + + @POST + @Path("/api/v1/query") + @Consumes("application/x-www-form-urlencoded") + QueryResponse postInstantQuery( + @FormParam("query") String query, + @FormParam("time") @InstantFormat(EPOCH_SECONDS) Instant time, + @FormParam("timeout") Dur timeout); + + @GET + @Path("/api/v1/query_range") + QueryResponse getRangeQuery( + @QueryParam("query") String query, + @QueryParam("start") @InstantFormat(EPOCH_SECONDS) Instant start, + @QueryParam("end") @InstantFormat(EPOCH_SECONDS) Instant end, + @QueryParam("step") Dur step, + @QueryParam("timeout") Dur timeout); + + @POST + @Path("/api/v1/query_range") + @Consumes("application/x-www-form-urlencoded") + QueryResponse postRangeQuery( + @FormParam("query") String query, + @FormParam("start") @InstantFormat(EPOCH_SECONDS) Instant start, + @FormParam("end") @InstantFormat(EPOCH_SECONDS) Instant end, + @FormParam("step") Dur step, + @FormParam("timeout") Dur timeout); + + @GET + @Path("/api/v1/series") + SeriesResponse getSeries( + @QueryParam("match[]") String seriesSelector, + @QueryParam("start") @InstantFormat(EPOCH_SECONDS) Instant start, + @QueryParam("end") @InstantFormat(EPOCH_SECONDS) Instant end); + + @POST + @Path("/api/v1/series") + @Consumes("application/x-www-form-urlencoded") + SeriesResponse postSeries( + @FormParam("match[]") String seriesSelector, + @FormParam("start") @InstantFormat(EPOCH_SECONDS) Instant start, + @FormParam("end") @InstantFormat(EPOCH_SECONDS) Instant end); + + @GET + @Path("/api/v1/labels") + LabelsResponse getLabels( + @QueryParam("match[]") String seriesSelector, + @QueryParam("start") @InstantFormat(EPOCH_SECONDS) Instant start, + @QueryParam("end") @InstantFormat(EPOCH_SECONDS) Instant end); + + @POST + @Path("/api/v1/labels") + @Consumes("application/x-www-form-urlencoded") + LabelsResponse postLabels( + @FormParam("match[]") String seriesSelector, + @FormParam("start") @InstantFormat(EPOCH_SECONDS) Instant start, + @FormParam("end") @InstantFormat(EPOCH_SECONDS) Instant end); + + @GET + @Path("/api/v1/label/{label}/values") + LabelsResponse getLabelValues( + @PathParam("label") String label, + @QueryParam("match[]") String seriesSelector, + @QueryParam("start") @InstantFormat(EPOCH_SECONDS) Instant start, + @QueryParam("end") @InstantFormat(EPOCH_SECONDS) Instant end); + + @POST + @Path("/api/v1/label/{label}/values") + @Consumes("application/x-www-form-urlencoded") + LabelsResponse postLabelValues( + @PathParam("label") String label, + @FormParam("match[]") String seriesSelector, + @FormParam("start") @InstantFormat(EPOCH_SECONDS) Instant start, + @FormParam("end") @InstantFormat(EPOCH_SECONDS) Instant end); +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Data.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Data.java new file mode 100644 index 00000000000000..ec161f8aa3840b --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Data.java @@ -0,0 +1,15 @@ +package io.quarkus.observability.promql.client.data; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "resultType") +@JsonSubTypes({ + @JsonSubTypes.Type(value = MatrixData.class, name = "matrix"), + @JsonSubTypes.Type(value = ScalarData.class, name = "scalar"), + @JsonSubTypes.Type(value = StringData.class, name = "string"), + @JsonSubTypes.Type(value = VectorData.class, name = "vector") +}) +public interface Data { + T result(); +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Dur.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Dur.java new file mode 100644 index 00000000000000..fd51133b73cd55 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Dur.java @@ -0,0 +1,93 @@ +package io.quarkus.observability.promql.client.data; + +import java.time.Duration; +import java.time.Period; + +import com.fasterxml.jackson.annotation.JsonCreator; + +@SuppressWarnings({ "checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity" }) +public class Dur { + + private final Period period; + private final Duration duration; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public Dur(Period period, Duration duration) { + if (period == null && duration == null) { + throw new IllegalArgumentException("At least one of 'period' or 'duration' should be specified"); + } + if (period != null) { + if (period.isNegative() || period.isZero()) { + throw new IllegalArgumentException("'period' should be positive"); + } + if (period.getYears() < 0 || period.getMonths() < 0 || period.getDays() < 0) { + throw new IllegalArgumentException("'period' fields should not be negative"); + } + if (period.getMonths() > 0) { + throw new IllegalArgumentException("'period' months field is not supported and should be zero"); + } + } + if (duration != null) { + if (duration.isNegative() || duration.isZero()) { + throw new IllegalArgumentException("'duration' should be positive"); + } + if (duration.getSeconds() < 0L || duration.getNano() < 0) { + throw new IllegalArgumentException("'duration' fields should not be negative"); + } + } + this.period = period; + this.duration = duration; + } + + public Dur(Period period) { + this(period, null); + } + + public Dur(Duration duration) { + this(null, duration); + } + + public Period getPeriod() { + return period; + } + + public Duration getDuration() { + return duration; + } + + // ms, s, m, h, d, w, y + + @Override + public String toString() { + var sb = new StringBuilder(); + if (period != null) { + var y = period.getYears(); + var d = period.getDays(); + var w = d / 7; + d = d % 7; + if (y > 0) + sb.append(y).append('y'); + if (w > 0) + sb.append(w).append('w'); + if (d > 0) + sb.append(d).append('d'); + } + if (duration != null) { + var s = duration.getSeconds(); + var h = s / 3600; + s = s % 3600; + var m = s / 60; + s = s % 60; + var ms = duration.getNano() / 1000_000; + if (h > 0) + sb.append(h).append('h'); + if (m > 0) + sb.append(m).append('m'); + if (s > 0) + sb.append(s).append('s'); + if (ms > 0) + sb.append(ms).append("ms"); + } + return sb.toString(); + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/LabelsResponse.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/LabelsResponse.java new file mode 100644 index 00000000000000..029cce6e93a2ea --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/LabelsResponse.java @@ -0,0 +1,29 @@ +package io.quarkus.observability.promql.client.data; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class LabelsResponse { + + @JsonProperty + private final Status status; + + @JsonProperty + private final List data; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public LabelsResponse(Status status, List data) { + this.status = status; + this.data = data; + } + + public Status status() { + return status; + } + + public List data() { + return data; + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/MatrixData.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/MatrixData.java new file mode 100644 index 00000000000000..2f41fe50b0fb24 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/MatrixData.java @@ -0,0 +1,22 @@ +package io.quarkus.observability.promql.client.data; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MatrixData implements Data> { + + @JsonProperty + private final List result; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public MatrixData(List result) { + this.result = result; + } + + @Override + public List result() { + return result; + } +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/MatrixResult.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/MatrixResult.java new file mode 100644 index 00000000000000..19c8c338028d0f --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/MatrixResult.java @@ -0,0 +1,30 @@ +package io.quarkus.observability.promql.client.data; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MatrixResult { + + @JsonProperty + private final Metric metric; + @JsonProperty + private final List values; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public MatrixResult( + Metric metric, + List values) { + this.metric = metric; + this.values = values; + } + + public Metric metric() { + return metric; + } + + public List values() { + return values; + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Metric.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Metric.java new file mode 100644 index 00000000000000..597fa13783c8b5 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Metric.java @@ -0,0 +1,60 @@ +package io.quarkus.observability.promql.client.data; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonProperty; + +public final class Metric { + private String name; + private final Map labels = new HashMap<>(); + + @JsonProperty("__name__") + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @JsonAnyGetter + public Map labels() { + return labels; + } + + @JsonAnySetter + public void setLabel(String name, String value) { + labels.put(name, value); + } + + @Override + public boolean equals(Object o) { + return this == o || + (o instanceof Metric) && + name.equals(((Metric) o).name) && + labels.equals(((Metric) o).labels); + } + + @Override + public int hashCode() { + return Objects.hash(name, labels); + } + + @Override + public String toString() { + return labels + .entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .map(e -> e.getKey() + "=\"" + e.getValue() + "\"") + .collect(Collectors.joining( + ",", + name == null ? "{" : name + "{", + "}")); + } +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/QueryResponse.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/QueryResponse.java new file mode 100644 index 00000000000000..38688d85a344fe --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/QueryResponse.java @@ -0,0 +1,57 @@ +package io.quarkus.observability.promql.client.data; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class QueryResponse { + @JsonProperty + private final Status status; + @JsonProperty + private final Data data; + @JsonProperty + private final String errorType; + @JsonProperty + private final String error; + @JsonProperty + private final List warnings; + @JsonProperty + private final Map stats; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public QueryResponse(Status status, Data data, String errorType, String error, List warnings, + Map stats) { + this.status = status; + this.data = data; + this.errorType = errorType; + this.error = error; + this.warnings = warnings; + this.stats = stats; + } + + public Status status() { + return status; + } + + public Data data() { + return data; + } + + public String errorType() { + return errorType; + } + + public String error() { + return error; + } + + public List warnings() { + return warnings; + } + + public Map stats() { + return stats; + } +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/ScalarData.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/ScalarData.java new file mode 100644 index 00000000000000..0e478188578ec1 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/ScalarData.java @@ -0,0 +1,19 @@ +package io.quarkus.observability.promql.client.data; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ScalarData implements Data { + @JsonProperty + private final ScalarResult result; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public ScalarData(ScalarResult result) { + this.result = result; + } + + @Override + public ScalarResult result() { + return result; + } +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/ScalarResult.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/ScalarResult.java new file mode 100644 index 00000000000000..ad48596d068b59 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/ScalarResult.java @@ -0,0 +1,102 @@ +package io.quarkus.observability.promql.client.data; + +import java.time.Instant; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.Converter; + +@JsonSerialize(converter = ScalarResult.ConvertToArray.class) +@JsonDeserialize(converter = ScalarResult.ConvertFromArray.class) +public class ScalarResult { + + private final Instant time; + private final double value; + + public ScalarResult( + Instant time, + double value) { + this.time = time; + this.value = value; + } + + public Instant time() { + return time; + } + + public double value() { + return value; + } + + public static class ConvertToArray implements Converter { + @Override + public Object[] convert(ScalarResult result) { + double epochSeconds = (double) result.time().getEpochSecond() + (double) result.time().getNano() / 1000_000_000d; + double value = result.value(); + String stringValue; + if (Double.isNaN(value)) { + stringValue = "NaN"; + } else if (Double.isInfinite(value)) { + if (value < 0) { + stringValue = "-Inf"; + } else { + stringValue = "Inf"; + } + } else { + stringValue = String.valueOf(value); + } + return new Object[] { epochSeconds, stringValue }; + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(ScalarResult.class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(Object[].class); + } + } + + public static class ConvertFromArray implements Converter { + @Override + public ScalarResult convert(Object[] tuple) { + if (tuple.length != 2) { + throw new IllegalArgumentException("Two elements expected in ScalarResult"); + } + double epochSeconds = ((Number) tuple[0]).doubleValue(); + Instant time = Instant.ofEpochSecond( + (long) epochSeconds, + ((long) (epochSeconds * 1000_000_000d)) % 1000_000_000L); + String stringValue = (String) tuple[1]; + double value = fromString(stringValue); + return new ScalarResult(time, value); + } + + private double fromString(String stringValue) { + switch (stringValue) { + case "NaN": + return Double.NaN; + case "-Inf": + return Double.NEGATIVE_INFINITY; + case "Inf": + return Double.POSITIVE_INFINITY; + default: + return Double.parseDouble(stringValue); + } + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(Object[].class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(ScalarResult.class); + } + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/SeriesResponse.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/SeriesResponse.java new file mode 100644 index 00000000000000..1fe2c590495655 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/SeriesResponse.java @@ -0,0 +1,28 @@ +package io.quarkus.observability.promql.client.data; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SeriesResponse { + @JsonProperty + private final Status status; + + @JsonProperty + private final List data; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public SeriesResponse(Status status, List data) { + this.status = status; + this.data = data; + } + + public Status status() { + return status; + } + + public List data() { + return data; + } +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Status.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Status.java new file mode 100644 index 00000000000000..9f66daf44d9b62 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/Status.java @@ -0,0 +1,6 @@ +package io.quarkus.observability.promql.client.data; + +public enum Status { + success, + error +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/StringData.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/StringData.java new file mode 100644 index 00000000000000..d528cb817ad44c --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/StringData.java @@ -0,0 +1,19 @@ +package io.quarkus.observability.promql.client.data; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class StringData implements Data { + @JsonProperty + private final StringResult result; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public StringData(StringResult result) { + this.result = result; + } + + @Override + public StringResult result() { + return result; + } +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/StringResult.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/StringResult.java new file mode 100644 index 00000000000000..2698e2e877a295 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/StringResult.java @@ -0,0 +1,73 @@ +package io.quarkus.observability.promql.client.data; + +import java.time.Instant; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.Converter; + +@JsonSerialize(converter = StringResult.ConvertToArray.class) +@JsonDeserialize(converter = StringResult.ConvertFromArray.class) +public class StringResult { + private final Instant time; + private final String value; + + public StringResult(Instant time, String value) { + this.time = time; + this.value = value; + } + + public Instant time() { + return time; + } + + public String value() { + return value; + } + + public static class ConvertToArray implements Converter { + @Override + public Object[] convert(StringResult result) { + double epochSeconds = (double) result.time().getEpochSecond() + (double) result.time().getNano() / 1000_000_000d; + String stringValue = result.value(); + return new Object[] { epochSeconds, stringValue }; + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(StringResult.class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(Object[].class); + } + } + + public static class ConvertFromArray implements Converter { + @Override + public StringResult convert(Object[] tuple) { + if (tuple.length != 2) { + throw new IllegalArgumentException("Two elements expected in StringResult"); + } + double epochSeconds = ((Number) tuple[0]).doubleValue(); + Instant time = Instant.ofEpochSecond( + (long) epochSeconds, + ((long) (epochSeconds * 1000_000_000d)) % 1000_000_000L); + String stringValue = (String) tuple[1]; + return new StringResult(time, stringValue); + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(Object[].class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(StringResult.class); + } + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/VectorData.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/VectorData.java new file mode 100644 index 00000000000000..b3e8a0a845c905 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/VectorData.java @@ -0,0 +1,22 @@ +package io.quarkus.observability.promql.client.data; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class VectorData implements Data> { + + @JsonProperty + private final List result; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public VectorData(List result) { + this.result = result; + } + + @Override + public List result() { + return result; + } +} \ No newline at end of file diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/VectorResult.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/VectorResult.java new file mode 100644 index 00000000000000..b702a529a490d2 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/data/VectorResult.java @@ -0,0 +1,28 @@ +package io.quarkus.observability.promql.client.data; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class VectorResult { + + @JsonProperty + private final Metric metric; + @JsonProperty + private final ScalarResult value; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public VectorResult( + Metric metric, + ScalarResult value) { + this.metric = metric; + this.value = value; + } + + public Metric metric() { + return metric; + } + + public ScalarResult value() { + return value; + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/AbstractParamConverterProvider.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/AbstractParamConverterProvider.java new file mode 100644 index 00000000000000..f3b7aad0dedc40 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/AbstractParamConverterProvider.java @@ -0,0 +1,34 @@ +package io.quarkus.observability.promql.client.rest; + +import java.util.Objects; +import java.util.function.Function; + +import jakarta.ws.rs.ext.ParamConverter; +import jakarta.ws.rs.ext.ParamConverterProvider; + +public abstract class AbstractParamConverterProvider implements ParamConverterProvider { + @SuppressWarnings({ "unchecked", "rawtypes", "unused" }) + protected static ParamConverter cast(Class rawType, ParamConverter paramConverter) { + return (ParamConverter) paramConverter; + } + + public static final class PC implements ParamConverter { + private final Function fromStringFn; + private final Function toStringFn; + + public PC(Function fromStringFn, Function toStringFn) { + this.fromStringFn = Objects.requireNonNull(fromStringFn); + this.toStringFn = Objects.requireNonNull(toStringFn); + } + + @Override + public T fromString(String value) { + return value == null ? null : fromStringFn.apply(value); + } + + @Override + public String toString(T value) { + return value == null ? null : toStringFn.apply(value); + } + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/DebugInputStream.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/DebugInputStream.java new file mode 100644 index 00000000000000..0940b09432d17f --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/DebugInputStream.java @@ -0,0 +1,75 @@ +package io.quarkus.observability.promql.client.rest; + +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.function.Consumer; + +public class DebugInputStream extends FilterInputStream { + private final Consumer debugOutput; + private final Charset charset; + private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + public DebugInputStream(InputStream in, Consumer debugOutput, Charset charset) { + super(in); + this.debugOutput = debugOutput; + this.charset = charset; + } + + @Override + public int read() throws IOException { + int b = super.read(); + if (b >= 0) { + if (b == '\n') { + var s = baos.toString(charset); + baos.reset(); + debugOutput.accept(s); + } else { + baos.write(b); + } + } + return b; + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int nread = super.read(b, off, len); + if (nread > 0) { + int start = off; + int end = off; + while (end < off + nread) { + if (b[end] == '\n') { + baos.write(b, start, end - start); + var s = baos.toString(charset); + baos.reset(); + debugOutput.accept(s); + start = end + 1; + end = start; + } else { + end++; + } + } + if (end > start) { + baos.write(b, start, end - start); + } + } + return nread; + } + + @Override + public void close() throws IOException { + super.close(); + if (baos.size() > 0) { + var s = baos.toString(charset); + baos.reset(); + debugOutput.accept(s); + } + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/DebugOutputStream.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/DebugOutputStream.java new file mode 100644 index 00000000000000..9fb96708c59f35 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/DebugOutputStream.java @@ -0,0 +1,69 @@ +package io.quarkus.observability.promql.client.rest; + +import java.io.ByteArrayOutputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.function.Consumer; + +public class DebugOutputStream extends FilterOutputStream { + private final Consumer debugOutput; + private final Charset charset; + private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + public DebugOutputStream(OutputStream out, Consumer debugOutput, Charset charset) { + super(out); + this.debugOutput = debugOutput; + this.charset = charset; + } + + @Override + public void write(int b) throws IOException { + super.write(b); + if (b == '\n') { + var s = baos.toString(charset); + baos.reset(); + debugOutput.accept(s); + } else { + baos.write(b); + } + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + super.write(b, off, len); + int start = off; + int end = off; + while (end < off + len) { + if (b[end] == '\n') { + baos.write(b, start, end - start); + var s = baos.toString(charset); + baos.reset(); + debugOutput.accept(s); + start = end + 1; + end = start; + } else { + end++; + } + } + if (end > start) { + baos.write(b, start, end - start); + } + } + + @Override + public void close() throws IOException { + super.close(); + if (baos.size() > 0) { + var s = baos.toString(charset); + baos.reset(); + debugOutput.accept(s); + } + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/InstantFormat.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/InstantFormat.java new file mode 100644 index 00000000000000..2b142ebba564ba --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/InstantFormat.java @@ -0,0 +1,45 @@ +package io.quarkus.observability.promql.client.rest; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.time.Instant; +import java.util.Locale; +import java.util.function.Function; + +/** + * Used in conjunction with {@link jakarta.ws.rs.QueryParam} or {@link jakarta.ws.rs.FormParam} to + * annotate parameters of type {@link Instant} to make the {@link InstantParamConverterProvider} + * provide with the Instant converter of desired {@link InstantFormat.Kind kind}. + */ +@Target({ ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@SuppressWarnings("checkstyle:Indentation") +public @interface InstantFormat { + + Kind value() default Kind.ISO; + + /** + * Enumeration of kinds of Instant formats. + */ + enum Kind { + ISO(Instant::parse, Instant::toString), + EPOCH_SECONDS( + string -> Instant.ofEpochMilli((long) (Double.parseDouble(string) * 1000d)), + value -> String.format(Locale.ROOT, "%f", (double) value.toEpochMilli() / 1000d)), + EPOCH_MILLIS( + string -> Instant.ofEpochMilli(Long.parseLong(string)), + value -> String.valueOf(value.toEpochMilli())); + + final Function fromString; + final Function toString; + + Kind(Function fromString, Function toString) { + this.fromString = fromString; + this.toString = toString; + } + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/InstantParamConverterProvider.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/InstantParamConverterProvider.java new file mode 100644 index 00000000000000..b73f5a81f9456e --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/InstantParamConverterProvider.java @@ -0,0 +1,44 @@ +package io.quarkus.observability.promql.client.rest; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.time.Instant; +import java.util.Arrays; +import java.util.stream.Stream; + +import jakarta.ws.rs.ConstrainedTo; +import jakarta.ws.rs.ext.ParamConverter; +import jakarta.ws.rs.ext.ParamConverterProvider; +import jakarta.ws.rs.ext.Provider; + +import org.jboss.logging.Logger; + +/** + * A {@link ParamConverterProvider} for some common types. To register, subclass and + * annotate with {@link Provider} and possibly {@link ConstrainedTo} annotations. + * + * @see InstantFormat + */ +public abstract class InstantParamConverterProvider extends AbstractParamConverterProvider { + protected final Logger log = Logger.getLogger(getClass()); + + @Override + public ParamConverter getConverter(Class rawType, Type genericType, Annotation[] annotations) { + if (log.isDebugEnabled()) { + log.debugf("getConverter(rawType=%s, annotations=%s)", rawType.getName(), Arrays.toString(annotations)); + } + + if (Instant.class.isAssignableFrom(rawType)) { + var instantFormatKind = Stream + .of(annotations) + .filter(InstantFormat.class::isInstance) + .map(InstantFormat.class::cast) + .findFirst() + .map(InstantFormat::value) + .orElse(InstantFormat.Kind.ISO); + + return cast(rawType, new PC<>(instantFormatKind.fromString, instantFormatKind.toString)); + } + return null; + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/PromQLParamConverterProvider.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/PromQLParamConverterProvider.java new file mode 100644 index 00000000000000..6aa3a568130d91 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/PromQLParamConverterProvider.java @@ -0,0 +1,34 @@ +package io.quarkus.observability.promql.client.rest; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; + +import jakarta.ws.rs.ConstrainedTo; +import jakarta.ws.rs.RuntimeType; +import jakarta.ws.rs.ext.ParamConverter; +import jakarta.ws.rs.ext.Provider; + +import io.quarkus.observability.promql.client.data.Dur; + +@Provider +@ConstrainedTo(RuntimeType.CLIENT) +public class PromQLParamConverterProvider extends InstantParamConverterProvider { + + @Override + public ParamConverter getConverter(Class rawType, Type genericType, Annotation[] annotations) { + var converter = super.getConverter(rawType, genericType, annotations); + if (converter != null) { + return converter; + } + if (Dur.class.isAssignableFrom(rawType)) { + return cast(rawType, DUR_PARAM_CONVERTER); + } + return null; + } + + private static final ParamConverter DUR_PARAM_CONVERTER = new PC<>( + string -> { + throw new UnsupportedOperationException("Parsing of Dur not implemented yet."); + }, + Dur::toString); +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/RequestDebugFilter.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/RequestDebugFilter.java new file mode 100644 index 00000000000000..d90e607155b68e --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/RequestDebugFilter.java @@ -0,0 +1,27 @@ +package io.quarkus.observability.promql.client.rest; + +import java.util.Map; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; + +import org.jboss.logging.Logger; + +public class RequestDebugFilter implements ClientRequestFilter { + private static final Logger log = Logger.getLogger(RequestDebugFilter.class.getPackageName() + ".>>>"); + + @Override + public void filter(ClientRequestContext requestContext) { + if (log.isDebugEnabled()) { + log.debugf("%s %s", requestContext.getMethod(), requestContext.getUri()); + requestContext + .getHeaders() + .entrySet() + .stream() + .flatMap(e -> e.getValue().stream().map(v -> Map.entry(e.getKey(), v))) + .forEach(e -> log.debugf("%s: %s", e.getKey(), e.getValue())); + log.debug(""); + log.debugf("(%s): %s", requestContext.getEntityClass(), requestContext.getEntity()); + } + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/ResponseDebugFilter.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/ResponseDebugFilter.java new file mode 100644 index 00000000000000..200978e87a1be4 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/rest/ResponseDebugFilter.java @@ -0,0 +1,38 @@ +package io.quarkus.observability.promql.client.rest; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientResponseContext; +import jakarta.ws.rs.client.ClientResponseFilter; + +import org.jboss.logging.Logger; + +public class ResponseDebugFilter implements ClientResponseFilter { + private static final Logger log = Logger.getLogger(ResponseDebugFilter.class.getPackageName() + ".<<<"); + + @Override + public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) { + if (log.isDebugEnabled()) { + log.debugf("%s %s", responseContext.getStatusInfo().getStatusCode(), responseContext.getStatusInfo().toEnum()); + var headers = responseContext.getHeaders(); + if (headers != null) { + headers + .entrySet() + .stream() + .flatMap(e -> e.getValue().stream().map(v -> Map.entry(e.getKey(), v))) + .forEach(e -> log.debugf("%s: %s", e.getKey(), e.getValue())); + } + log.debug(""); + var entityStream = responseContext.getEntityStream(); + if (entityStream != null) { + responseContext.setEntityStream( + new DebugInputStream( + entityStream, + log::debug, + StandardCharsets.UTF_8)); + } + } + } +} diff --git a/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/util/ObservabilityObjectMapperFactory.java b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/util/ObservabilityObjectMapperFactory.java new file mode 100644 index 00000000000000..e36041e87f7148 --- /dev/null +++ b/extensions/observability/promql/src/main/java/io/quarkus/observability/promql/client/util/ObservabilityObjectMapperFactory.java @@ -0,0 +1,31 @@ +package io.quarkus.observability.promql.client.util; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; + +public class ObservabilityObjectMapperFactory { + /** + * @return Common ObjectMapper supporting parameter names. + */ + public static ObjectMapper createObjectMapper() { + return new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE) + .registerModule(new ParameterNamesModule()) + .registerModule(new JavaTimeModule()) + .registerModule(new Jdk8Module()) + .disable( + SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, + SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) + .enable( + SerializationFeature.WRITE_DATES_WITH_ZONE_ID) + .disable( + DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) + .disable( + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } +} diff --git a/extensions/observability/promql/src/main/resources/META-INF/beans.xml b/extensions/observability/promql/src/main/resources/META-INF/beans.xml new file mode 100644 index 00000000000000..330c7f60e1bfda --- /dev/null +++ b/extensions/observability/promql/src/main/resources/META-INF/beans.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/observability/promql/src/test/java/io/quarkus/observability/promql/client/test/PromQLConfiguration.java b/extensions/observability/promql/src/test/java/io/quarkus/observability/promql/client/test/PromQLConfiguration.java new file mode 100644 index 00000000000000..f26af25d19d8b6 --- /dev/null +++ b/extensions/observability/promql/src/test/java/io/quarkus/observability/promql/client/test/PromQLConfiguration.java @@ -0,0 +1,16 @@ +package io.quarkus.observability.promql.client.test; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Singleton; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.observability.promql.client.util.ObservabilityObjectMapperFactory; + +@ApplicationScoped +public class PromQLConfiguration { + @Singleton + public ObjectMapper objectMapper() { + return ObservabilityObjectMapperFactory.createObjectMapper(); + } +} diff --git a/extensions/observability/promql/src/test/java/io/quarkus/observability/promql/client/test/PromQLDataTest.java b/extensions/observability/promql/src/test/java/io/quarkus/observability/promql/client/test/PromQLDataTest.java new file mode 100644 index 00000000000000..a3d35bc7f068f9 --- /dev/null +++ b/extensions/observability/promql/src/test/java/io/quarkus/observability/promql/client/test/PromQLDataTest.java @@ -0,0 +1,78 @@ +package io.quarkus.observability.promql.client.test; + +import java.io.InputStream; +import java.time.Duration; +import java.time.Period; +import java.util.List; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.observability.promql.client.PromQLService; +import io.quarkus.observability.promql.client.data.Dur; +import io.quarkus.observability.promql.client.data.QueryResponse; +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +public class PromQLDataTest { + private static final Logger log = Logger.getLogger(PromQLDataTest.class); + + @Inject + @RestClient + PromQLService service; + + @Inject + ObjectMapper objectMapper; + + @Test + public void testInjections() { + Assertions.assertNotNull(service, "PromQLService not injected"); + } + + @Test + public void testDeserialize() throws Exception { + Assertions.assertNotNull(objectMapper, "ObjectMapper not injected"); + + for (String rt : List.of("matrix", "scalar", "string", "vector")) { + try (InputStream stream = getClass().getClassLoader().getResourceAsStream(rt + ".json")) { + QueryResponse response = objectMapper.readValue(stream, QueryResponse.class); + Assertions.assertNotNull(response); + log.infof("response = %s", response); + Assertions.assertEquals("Dummy", response.errorType()); + Assertions.assertEquals(List.of("W1", "W2"), response.warnings()); + } + } + } + + @Test + public void testDuration() { + Assertions.assertEquals("1h2m3s", new Dur(Duration.parse("PT1H2M3S")).toString()); + Assertions.assertEquals("2y1w5d", new Dur(Period.parse("P2Y12D")).toString()); + Assertions.assertEquals("2y1w5d1h2m3s", new Dur(Period.parse("P2Y12D"), Duration.parse("PT1H2M3S")).toString()); + + testInvalid(null, null); + testInvalid(Period.ofYears(0), null); + testInvalid(Period.ofYears(-1), null); + testInvalid(Period.ofMonths(-1), null); + testInvalid(Period.ofMonths(1), null); + testInvalid(Period.ofDays(-1), null); + testInvalid(null, Duration.ofSeconds(0)); + testInvalid(null, Duration.ofSeconds(-1)); + testInvalid(null, Duration.ofNanos(-1)); + } + + void testInvalid(Period period, Duration duration) { + try { + var dur = new Dur(period, duration); + throw new RuntimeException("Unexpected Dur: " + dur); + } catch (IllegalArgumentException expected) { + log.infof("Expected exception: %s", expected.toString()); + } + } +} diff --git a/extensions/observability/promql/src/test/resources/application.properties b/extensions/observability/promql/src/test/resources/application.properties new file mode 100644 index 00000000000000..d72b431dd5ab3f --- /dev/null +++ b/extensions/observability/promql/src/test/resources/application.properties @@ -0,0 +1,6 @@ +# VictoriaMetrics & PromQL +quarkus.rest-client.promql.url=http://localhost:8428 +quarkus.rest-client.promql.scope=jakarta.inject.Singleton + +quarkus.log.category."io.quarkus.observability.promql.client".level=DEBUG +quarkus.log.category."io.quarkus.observability.promql.client.rest".level=DEBUG diff --git a/extensions/observability/promql/src/test/resources/matrix.json b/extensions/observability/promql/src/test/resources/matrix.json new file mode 100644 index 00000000000000..4618cbd09571d8 --- /dev/null +++ b/extensions/observability/promql/src/test/resources/matrix.json @@ -0,0 +1,38 @@ +{ + "status" : "success", + "data" : { + "resultType" : "matrix", + "result" : [ + { + "metric" : { + "__name__" : "up", + "job" : "prometheus", + "instance" : "localhost:9090" + }, + "values" : [ + [ 1435781430.781, "1" ], + [ 1435781445.781, "1" ], + [ 1435781460.781, "1" ] + ] + }, + { + "metric" : { + "__name__" : "up", + "job" : "node", + "instance" : "localhost:9091" + }, + "values" : [ + [ 1435781430.781, "0" ], + [ 1435781445.781, "0" ], + [ 1435781460.781, "1" ] + ] + } + ] + }, + "errorType": "Dummy", + "error": "Test dummy crash course", + "warnings": [ + "W1", + "W2" + ] +} \ No newline at end of file diff --git a/extensions/observability/promql/src/test/resources/scalar.json b/extensions/observability/promql/src/test/resources/scalar.json new file mode 100644 index 00000000000000..864f7a41ef89fd --- /dev/null +++ b/extensions/observability/promql/src/test/resources/scalar.json @@ -0,0 +1,13 @@ +{ + "status" : "success", + "data" : { + "resultType" : "scalar", + "result" : [1435781451.781, "2.3"] + }, + "errorType": "Dummy", + "error": "Test dummy crash course", + "warnings": [ + "W1", + "W2" + ] +} \ No newline at end of file diff --git a/extensions/observability/promql/src/test/resources/string.json b/extensions/observability/promql/src/test/resources/string.json new file mode 100644 index 00000000000000..8c9d8491ff0667 --- /dev/null +++ b/extensions/observability/promql/src/test/resources/string.json @@ -0,0 +1,13 @@ +{ + "status" : "success", + "data" : { + "resultType" : "string", + "result" : [1435781451.781, "foobar"] + }, + "errorType": "Dummy", + "error": "Test dummy crash course", + "warnings": [ + "W1", + "W2" + ] +} \ No newline at end of file diff --git a/extensions/observability/promql/src/test/resources/vector.json b/extensions/observability/promql/src/test/resources/vector.json new file mode 100644 index 00000000000000..1ba54c2955aaab --- /dev/null +++ b/extensions/observability/promql/src/test/resources/vector.json @@ -0,0 +1,30 @@ +{ + "status" : "success", + "data" : { + "resultType" : "vector", + "result" : [ + { + "metric" : { + "__name__" : "up", + "job" : "prometheus", + "instance" : "localhost:9090" + }, + "value": [ 1435781451.781, "1" ] + }, + { + "metric" : { + "__name__" : "up", + "job" : "node", + "instance" : "localhost:9100" + }, + "value" : [ 1435781451.781, "0" ] + } + ] + }, + "errorType": "Dummy", + "error": "Test dummy crash course", + "warnings": [ + "W1", + "W2" + ] +} \ No newline at end of file diff --git a/extensions/observability/runtime/pom.xml b/extensions/observability/runtime/pom.xml new file mode 100644 index 00000000000000..5b373b8291982d --- /dev/null +++ b/extensions/observability/runtime/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + quarkus-observability-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-observability + Quarkus - Observability - Runtime + Serve and consume Observability devservices + + + io.quarkus + quarkus-core + + + + io.quarkus + quarkus-observability-common + + + io.quarkus + quarkus-observability-devresource + + + + + io.quarkus + quarkus-junit5-internal + test + + + org.awaitility + awaitility + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + generate-extension-descriptor + + extension-descriptor + + process-resources + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/DevResourceShutdownRecorder.java b/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/DevResourceShutdownRecorder.java new file mode 100644 index 00000000000000..0d63c09b081dc0 --- /dev/null +++ b/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/DevResourceShutdownRecorder.java @@ -0,0 +1,12 @@ +package io.quarkus.observability.runtime; + +import io.quarkus.observability.devresource.DevResources; +import io.quarkus.runtime.ShutdownContext; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class DevResourceShutdownRecorder { + public void shutdown(ShutdownContext context) { + context.addLastShutdownTask(DevResources::stop); + } +} diff --git a/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/DevResourcesConfigBuilder.java b/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/DevResourcesConfigBuilder.java new file mode 100644 index 00000000000000..8d327a1d4bb0cd --- /dev/null +++ b/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/DevResourcesConfigBuilder.java @@ -0,0 +1,18 @@ +package io.quarkus.observability.runtime; + +import io.quarkus.observability.devresource.DevResourcesConfigSource; +import io.quarkus.runtime.configuration.ConfigBuilder; +import io.smallrye.config.SmallRyeConfigBuilder; + +public class DevResourcesConfigBuilder implements ConfigBuilder { + @Override + public SmallRyeConfigBuilder configBuilder(SmallRyeConfigBuilder builder) { + return builder.withSources(new DevResourcesConfigSource()); + } + + @Override + public int priority() { + // greater than any default Microprofile ConfigSource + return 500; + } +} diff --git a/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/config/ObservabilityConfiguration.java b/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/config/ObservabilityConfiguration.java new file mode 100644 index 00000000000000..e9327be656c1ef --- /dev/null +++ b/extensions/observability/runtime/src/main/java/io/quarkus/observability/runtime/config/ObservabilityConfiguration.java @@ -0,0 +1,35 @@ +package io.quarkus.observability.runtime.config; + +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.observability") +@ConfigRoot(phase = ConfigPhase.BUILD_TIME) +public interface ObservabilityConfiguration extends ModulesConfiguration { + /** + * If DevServices has been explicitly enabled or disabled. DevServices is generally enabled + * by default, unless there is an existing configuration present. + *

+ * When DevServices is enabled Quarkus will attempt to automatically configure and start + * a containers when running in Dev or Test mode and when Docker is running. + */ + @WithDefault("true") + boolean enabled(); + + /** + * Enable simplified usage of dev resources, + * instead of full observability processing. + * Make sure @code{enabled} is set to false. + */ + @WithDefault("false") + boolean devResources(); + + /** + * Do we start the dev services in parallel. + */ + @WithDefault("false") + boolean parallel(); +} diff --git a/extensions/observability/testcontainers/pom.xml b/extensions/observability/testcontainers/pom.xml new file mode 100644 index 00000000000000..cd7f63da12e329 --- /dev/null +++ b/extensions/observability/testcontainers/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + quarkus-observability-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-testcontainers + Quarkus Observability - Testcontainers + Quarkus Observability - Testcontainers + + + + io.quarkus + quarkus-devservices-common + + + io.quarkus + quarkus-observability-common + + + org.testcontainers + testcontainers + + + junit + junit + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + io.quarkus + quarkus-junit4-mock + + + org.junit.jupiter + junit-jupiter-api + test + + + + \ No newline at end of file diff --git a/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/GrafanaContainer.java b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/GrafanaContainer.java new file mode 100644 index 00000000000000..f08c3ad38cbf8e --- /dev/null +++ b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/GrafanaContainer.java @@ -0,0 +1,80 @@ +package io.quarkus.observability.testcontainers; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.Set; + +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.observability.common.config.AbstractContainerConfig; +import io.quarkus.observability.common.config.ConfigUtils; +import io.quarkus.observability.common.config.GrafanaConfig; +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.observability.common.config.VictoriaMetricsConfig; + +@SuppressWarnings("resource") +public class GrafanaContainer extends ObservabilityContainer { + protected static final String GRAFANA_NETWORK_ALIAS = "grafana.testcontainer.docker"; + protected static final String DATASOURCES_PATH = "/etc/grafana/provisioning/datasources/custom.yaml"; + + private final GrafanaConfig config; + private final ModulesConfiguration root; + + // TODO -- configure? + private String username = "admin"; + private String password = "password"; + private int port = 3000; + + public GrafanaContainer() { + this(new GrafanaConfigImpl(), null); + } + + public GrafanaContainer(GrafanaConfig config, ModulesConfiguration root) { + super(config); + this.config = config; + this.root = root; + withEnv("GF_SECURITY_ADMIN_USER", username); + withEnv("GF_SECURITY_ADMIN_PASSWORD", password); + withExposedPorts(port); + waitingFor(grafanaWaitStrategy()); + } + + protected WaitStrategy grafanaWaitStrategy() { + return new HttpWaitStrategy() + .forPath("/") + .forPort(port) + .forStatusCode(200); + } + + @Override + protected void containerIsCreated(String containerId) { + super.containerIsCreated(containerId); + byte[] datasources = getResourceAsBytes(config.datasourcesFile()); + String content = new String(datasources, StandardCharsets.UTF_8); + String vmEndpoint = "victoria-metrics:8428"; + if (root != null) { + VictoriaMetricsConfig vmc = root.victoriaMetrics(); + vmEndpoint = ConfigUtils.vmEndpoint(vmc); + } + content = content.replace("xTARGETx", vmEndpoint); + addFileToContainer(content.getBytes(StandardCharsets.UTF_8), DATASOURCES_PATH); + } + + private static class GrafanaConfigImpl extends AbstractContainerConfig implements GrafanaConfig { + public GrafanaConfigImpl() { + super(ContainerConstants.GRAFANA); + } + + @Override + public Optional> networkAliases() { + return Optional.of(Set.of("grafana", GRAFANA_NETWORK_ALIAS)); + } + + @Override + public String datasourcesFile() { + return "datasources.yaml"; + } + } +} diff --git a/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/JaegerContainer.java b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/JaegerContainer.java new file mode 100644 index 00000000000000..41529d8bbec667 --- /dev/null +++ b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/JaegerContainer.java @@ -0,0 +1,52 @@ +package io.quarkus.observability.testcontainers; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.Set; + +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.observability.common.config.AbstractContainerConfig; +import io.quarkus.observability.common.config.JaegerConfig; + +@SuppressWarnings("resource") +public class JaegerContainer extends ObservabilityContainer { + public static final int JAEGER_ENDPOINT_PORT = 14250; + public static final int JAEGER_CONSOLE_PORT = 16686; + + public JaegerContainer() { + this(new JaegerConfigImpl()); + } + + public JaegerContainer(JaegerConfig config) { + super(config); + withExposedPorts(JAEGER_ENDPOINT_PORT, JAEGER_CONSOLE_PORT); + + LogMessageWaitStrategy lmws = new LogMessageWaitStrategy(); + waitingFor(lmws.withRegEx( + ".*\"Health Check state change\",\"status\":\"ready\".*") + .withStartupTimeout(Duration.of(15L, ChronoUnit.SECONDS))); + } + + public int getJaegerEndpointPort() { + return getMappedPort(JAEGER_ENDPOINT_PORT); + } + + public int getJaegerConsolePort() { + return getMappedPort(JAEGER_CONSOLE_PORT); + } + + private static class JaegerConfigImpl extends AbstractContainerConfig implements JaegerConfig { + public JaegerConfigImpl() { + super(ContainerConstants.JAEGER); + } + + @Override + public Optional> networkAliases() { + return Optional.of(Set.of("jaeger")); + } + } + +} diff --git a/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/OTelCollectorContainer.java b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/OTelCollectorContainer.java new file mode 100644 index 00000000000000..a04b44613b2173 --- /dev/null +++ b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/OTelCollectorContainer.java @@ -0,0 +1,133 @@ +package io.quarkus.observability.testcontainers; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.observability.common.config.AbstractContainerConfig; +import io.quarkus.observability.common.config.ConfigUtils; +import io.quarkus.observability.common.config.JaegerConfig; +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.observability.common.config.OTelConfig; +import io.quarkus.observability.common.config.VictoriaMetricsConfig; +import io.quarkus.observability.testcontainers.support.OTelYaml; + +import static io.quarkus.runtime.configuration.ConfigUtils.getFirstOptionalValue; + +@SuppressWarnings("resource") +public class OTelCollectorContainer extends ObservabilityContainer { + protected static final String CONFIG_PATH = "/etc/otelcol-contrib/config.yaml"; + + public static final int OTEL_GRPC_EXPORTER_PORT = 4317; + public static final int OTEL_HTTP_EXPORTER_PORT = 4318; + + private final OTelConfig config; + private final ModulesConfiguration root; + + public OTelCollectorContainer() { + this(new OTelConfigImpl(), null); + } + + public OTelCollectorContainer(OTelConfig config, ModulesConfiguration root) { + super(config); + this.config = config; + this.root = root; + withExposedPorts(OTEL_GRPC_EXPORTER_PORT, OTEL_HTTP_EXPORTER_PORT); + } + + @Override + protected void containerIsCreated(String containerId) { + super.containerIsCreated(containerId); + byte[] config; + if (this.config.configFile().isPresent()) { + config = getResourceAsBytes(this.config.configFile().get()); + } else { + if (root == null) { + config = getResourceAsBytes("otel-collector-config.yaml"); + } else { + config = generateConfig(); + } + } + addFileToContainer(config, CONFIG_PATH); + } + + private byte[] generateConfig() { + byte[] config = getResourceAsBytes("otel-collector-config-template.yaml"); + try { + YAMLMapper yaml = new YAMLMapper(); + yaml.setSerializationInclusion(JsonInclude.Include.NON_NULL); + yaml.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + OTelYaml otelYaml = yaml.readValue(config, OTelYaml.class); + // processor, extension - add explicitly + otelYaml.processors.put("batch", new OTelYaml.Processor()); + otelYaml.extensions.put("health_check", new OTelYaml.Extension()); + JaegerConfig jaegerConfig = root.jaeger(); + if (ConfigUtils.isEnabled(jaegerConfig)) { + // exporter + OTelYaml.Exporter exporter = new OTelYaml.Exporter(); + exporter.endpoint = "jaeger:14250"; + OTelYaml.Tls tls = new OTelYaml.Tls(); + tls.insecure = true; + exporter.tls = tls; + otelYaml.exporters.put("jaeger", exporter); + // service + OTelYaml.Pipeline pipeline = new OTelYaml.Pipeline(); + pipeline.receivers = List.of("otlp"); + pipeline.processors = List.of("batch"); + pipeline.exporters = List.of("jaeger"); + otelYaml.service.pipelines.put("traces", pipeline); + } + VictoriaMetricsConfig vmConfig = root.victoriaMetrics(); + if (ConfigUtils.isEnabled(vmConfig)) { + // exporter + OTelYaml.Exporter exporter = new OTelYaml.Exporter(); + exporter.endpoint = ConfigUtils.vmEndpoint(vmConfig); + exporter.namespace = "quarkus_observability"; + otelYaml.exporters.put("prometheus", exporter); + // service + OTelYaml.Pipeline metrics = otelYaml.service.pipelines.get("metrics"); + List exs = metrics.exporters; + List newExs = new ArrayList<>(exs); + newExs.add("prometheus"); + metrics.exporters = newExs; + } + return yaml.writeValueAsBytes(otelYaml); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public int getOtelGrpcExporterPort() { + return getMappedPort(OTEL_GRPC_EXPORTER_PORT); + } + + public int getOtelHttpExporterPort() { + return getMappedPort(OTEL_HTTP_EXPORTER_PORT); + } + + private static class OTelConfigImpl extends AbstractContainerConfig implements OTelConfig { + public OTelConfigImpl() { + super(ContainerConstants.OTEL); + } + + @Override + public Optional> networkAliases() { + return Optional.of(Set.of("otel-collector")); + } + + @Override + public Optional configFile() { + return getFirstOptionalValue(List.of("quarkus.observability.otel.config-file"), String.class) + .or(() -> Optional.of("otel-collector-config.yaml")); + } + } + +} diff --git a/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/ObservabilityContainer.java b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/ObservabilityContainer.java new file mode 100644 index 00000000000000..21ae98aafafa16 --- /dev/null +++ b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/ObservabilityContainer.java @@ -0,0 +1,66 @@ +package io.quarkus.observability.testcontainers; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import io.quarkus.observability.common.config.ContainerConfig; + +@SuppressWarnings("resource") +public abstract class ObservabilityContainer, C extends ContainerConfig> + extends GenericContainer { + private final Logger log = LoggerFactory.getLogger(getClass()); + private final Logger dockerLog = LoggerFactory.getLogger(getClass().getName() + ".docker"); + + public ObservabilityContainer(C config) { + super(DockerImageName.parse(config.imageName())); + withLogConsumer(frame -> logger().debug(frame.getUtf8String().stripTrailing())); + withLabel(config.label(), config.serviceName()); + Optional> aliases = config.networkAliases(); + aliases.map(s -> s.toArray(new String[0])).ifPresent(this::withNetworkAliases); + if (config.shared()) { + withNetwork(Network.SHARED); + } + } + + protected byte[] getResourceAsBytes(String resource) { + try (InputStream in = getClass().getClassLoader().getResourceAsStream(resource)) { + return in.readAllBytes(); + } catch (IOException ioe) { + throw new UncheckedIOException(ioe); + } + } + + @SuppressWarnings("OctalInteger") + protected void addFileToContainer(byte[] content, String pathInContainer) { + logger().info("Content [{}]: \n{}", pathInContainer, new String(content, StandardCharsets.UTF_8)); + copyFileToContainer(Transferable.of(content, 0777), pathInContainer); + } + + @Override + protected Logger logger() { + return dockerLog; + } + + @Override + public void start() { + log.info("Starting {} ...", getClass().getSimpleName()); + super.start(); + } + + @Override + public void stop() { + log.info("Stopping {}...", getClass().getSimpleName()); + super.stop(); + } +} diff --git a/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/VMAgentContainer.java b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/VMAgentContainer.java new file mode 100644 index 00000000000000..a7fb0f37df92e6 --- /dev/null +++ b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/VMAgentContainer.java @@ -0,0 +1,61 @@ +package io.quarkus.observability.testcontainers; + +import java.nio.charset.StandardCharsets; +import java.util.OptionalInt; + +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.observability.common.config.AbstractContainerConfig; +import io.quarkus.observability.common.config.VMAgentConfig; + +public class VMAgentContainer extends ObservabilityContainer { + private static final String CONFIG_PATH = "/etc/prometheus/prometheus.yml"; + + private static final String CONFIG_YAML = "scrape_configs:\n" + + "- job_name: observability_metrics\n" + + " static_configs:\n" + + " - targets:\n" + + " - xTARGETx\n" + + " scrape_interval: 5s\n"; + + private final int port; + + public VMAgentContainer(String vmEndpoint, int scrapePort) { + this(new VMAgentConfigImpl(), vmEndpoint, scrapePort); + } + + public VMAgentContainer(VMAgentConfig config, String vmEndpoint, int scrapePort) { + super(config); + this.port = scrapePort; + setCommandParts(new String[] { + "-promscrape.config=" + CONFIG_PATH, + "-remoteWrite.url=" + vmEndpoint + "/api/v1/write" + }); + } + + protected String getConfig() { + return CONFIG_YAML.replace( + "xTARGETx", + String.format("http://%s:%s/q/metrics", GenericContainer.INTERNAL_HOST_HOSTNAME, port)); + } + + @Override + protected void containerIsCreated(String containerId) { + super.containerIsCreated(containerId); + String config = getConfig(); + addFileToContainer(config.getBytes(StandardCharsets.UTF_8), CONFIG_PATH); + } + + private static class VMAgentConfigImpl extends AbstractContainerConfig implements VMAgentConfig { + public VMAgentConfigImpl() { + super(ContainerConstants.VM_AGENT); + } + + @Override + public OptionalInt scrapePort() { + return OptionalInt.empty(); + } + } + +} diff --git a/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/VictoriaMetricsContainer.java b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/VictoriaMetricsContainer.java new file mode 100644 index 00000000000000..965985fe526088 --- /dev/null +++ b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/VictoriaMetricsContainer.java @@ -0,0 +1,50 @@ +package io.quarkus.observability.testcontainers; + +import java.util.Optional; +import java.util.Set; + +import io.quarkus.observability.common.ContainerConstants; +import io.quarkus.observability.common.config.AbstractContainerConfig; +import io.quarkus.observability.common.config.VictoriaMetricsConfig; + +@SuppressWarnings("resource") +public class VictoriaMetricsContainer extends ObservabilityContainer { + + private final int port; + + public VictoriaMetricsContainer() { + this(new VictoriaMetricsConfigImpl()); + } + + public VictoriaMetricsContainer(VictoriaMetricsConfig config) { + super(config); + this.port = config.port(); + withExposedPorts(port); + } + + public VictoriaMetricsContainer withMappedPort(int port) { + addFixedExposedPort(port, this.port); + return this; + } + + public String getEndpoint(boolean secure) { + return "http" + (secure ? "s" : "") + "://" + getHost() + ":" + getFirstMappedPort(); + } + + private static class VictoriaMetricsConfigImpl extends AbstractContainerConfig implements VictoriaMetricsConfig { + public VictoriaMetricsConfigImpl() { + super(ContainerConstants.VICTORIA_METRICS); + } + + @Override + public Optional> networkAliases() { + return Optional.of(Set.of("victoria-metrics")); + } + + @Override + public int port() { + return 8428; + } + } + +} diff --git a/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/support/OTelYaml.java b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/support/OTelYaml.java new file mode 100644 index 00000000000000..6f9451f8b9a851 --- /dev/null +++ b/extensions/observability/testcontainers/src/main/java/io/quarkus/observability/testcontainers/support/OTelYaml.java @@ -0,0 +1,49 @@ +package io.quarkus.observability.testcontainers.support; + +import java.util.List; +import java.util.Map; + +public class OTelYaml { + public Map receivers; + public Map exporters; + public Map processors; + public Map extensions; + public Service service; + + public static class Receiver { + public Map protocols; + } + + public static class Protocol { + public String endpoint; + } + + public static class Exporter { + public String endpoint; + public Tls tls; + public String loglevel; + public String namespace; + } + + public static class Tls { + public boolean insecure; + } + + public static class Processor { + } + + public static class Extension { + } + + public static class Service { + public List extensions; + public Map pipelines; + } + + public static class Pipeline { + public List receivers; + public List processors; + public List exporters; + } + +} diff --git a/extensions/observability/testcontainers/src/main/resources/datasources.yaml b/extensions/observability/testcontainers/src/main/resources/datasources.yaml new file mode 100644 index 00000000000000..152a56386eafbc --- /dev/null +++ b/extensions/observability/testcontainers/src/main/resources/datasources.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 +datasources: + - name: VictoriaMetrics + type: prometheus + url: http://xTARGETx + access: proxy + isDefault: true + jsonData: + timeInterval: 30s diff --git a/extensions/observability/testcontainers/src/main/resources/otel-collector-config-template.yaml b/extensions/observability/testcontainers/src/main/resources/otel-collector-config-template.yaml new file mode 100644 index 00000000000000..321168e6367a3c --- /dev/null +++ b/extensions/observability/testcontainers/src/main/resources/otel-collector-config-template.yaml @@ -0,0 +1,25 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +exporters: + logging: + loglevel: debug + +processors: + batch: + +extensions: + health_check: + +service: + extensions: [ health_check ] + pipelines: + metrics: + receivers: [ otlp ] + processors: [ ] + exporters: [ logging ] \ No newline at end of file diff --git a/extensions/observability/testcontainers/src/main/resources/otel-collector-config.yaml b/extensions/observability/testcontainers/src/main/resources/otel-collector-config.yaml new file mode 100644 index 00000000000000..42b3d1658a5a0e --- /dev/null +++ b/extensions/observability/testcontainers/src/main/resources/otel-collector-config.yaml @@ -0,0 +1,36 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +exporters: + logging: + loglevel: debug + jaeger: + endpoint: jaeger:14250 + tls: + insecure: true + prometheus: + endpoint: 0.0.0.0:8428 + namespace: quarkus_observability + +processors: + batch: + +extensions: + health_check: + +service: + extensions: [ health_check ] + pipelines: + traces: + receivers: [ otlp ] + processors: [ batch ] + exporters: [ jaeger ] + metrics: + receivers: [ otlp ] + processors: [ ] + exporters: [ logging,prometheus ] \ No newline at end of file diff --git a/extensions/observability/testcontainers/src/test/java/io/quarkus/observability/testcontainers/test/OTelYamlTest.java b/extensions/observability/testcontainers/src/test/java/io/quarkus/observability/testcontainers/test/OTelYamlTest.java new file mode 100644 index 00000000000000..573dbd52d26437 --- /dev/null +++ b/extensions/observability/testcontainers/src/test/java/io/quarkus/observability/testcontainers/test/OTelYamlTest.java @@ -0,0 +1,61 @@ +package io.quarkus.observability.testcontainers.test; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; + +import io.quarkus.observability.testcontainers.support.OTelYaml; + +public class OTelYamlTest { + + @Test + public void testYaml() throws Exception { + YAMLMapper yaml = new YAMLMapper(); + yaml.setSerializationInclusion(JsonInclude.Include.NON_NULL); + yaml.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + String config; + try (InputStream is = getClass().getClassLoader().getResourceAsStream("otel-collector-config-template.yaml")) { + OTelYaml otelYaml = yaml.readValue(is, OTelYaml.class); + // processor, extension + otelYaml.processors.put("batch", new OTelYaml.Processor()); + otelYaml.extensions.put("health_check", new OTelYaml.Extension()); + // exporter + OTelYaml.Exporter exporter1 = new OTelYaml.Exporter(); + exporter1.endpoint = "jaeger:14250"; + OTelYaml.Tls tls = new OTelYaml.Tls(); + tls.insecure = true; + exporter1.tls = tls; + otelYaml.exporters.put("jaeger", exporter1); + // service + OTelYaml.Pipeline pipeline = new OTelYaml.Pipeline(); + pipeline.receivers = List.of("otlp"); + pipeline.processors = List.of("batch"); + pipeline.exporters = List.of("jaeger"); + otelYaml.service.pipelines.put("traces", pipeline); + // exporter + OTelYaml.Exporter exporter2 = new OTelYaml.Exporter(); + exporter2.endpoint = "victoria-metrics:8428"; + exporter2.namespace = "quarkus_observability"; + otelYaml.exporters.put("prometheus", exporter2); + // service + OTelYaml.Pipeline metrics = otelYaml.service.pipelines.get("metrics"); + List exs = metrics.exporters; + List newExs = new ArrayList<>(exs); + newExs.add("prometheus"); + metrics.exporters = newExs; + // dump + config = yaml.writeValueAsString(otelYaml); + } + System.out.println(config); + try (InputStream is = getClass().getClassLoader().getResourceAsStream("otel-collector-config.yaml")) { + OTelYaml oTelYaml = yaml.readValue(is, OTelYaml.class); + System.out.println(yaml.writeValueAsString(oTelYaml)); + } + } +} diff --git a/extensions/observability/testlibs/devresource-grafana/pom.xml b/extensions/observability/testlibs/devresource-grafana/pom.xml new file mode 100644 index 00000000000000..1df75f8a51b0ed --- /dev/null +++ b/extensions/observability/testlibs/devresource-grafana/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + quarkus-observability-testlibs + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-devresource-grafana + Quarkus - Observability - Grafana DevResource + Grafana DevResource + + + true + + + + + io.quarkus + quarkus-observability-devresource + + + io.quarkus + quarkus-observability-testcontainers + + + io.quarkus + quarkus-test-common + + + + \ No newline at end of file diff --git a/extensions/observability/testlibs/devresource-grafana/src/main/java/io/quarkus/observability/devresource/grafana/GrafanaResource.java b/extensions/observability/testlibs/devresource-grafana/src/main/java/io/quarkus/observability/devresource/grafana/GrafanaResource.java new file mode 100644 index 00000000000000..d87f3983a13c47 --- /dev/null +++ b/extensions/observability/testlibs/devresource-grafana/src/main/java/io/quarkus/observability/devresource/grafana/GrafanaResource.java @@ -0,0 +1,47 @@ +package io.quarkus.observability.devresource.grafana; + +import java.util.Map; + +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.observability.common.config.GrafanaConfig; +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.observability.devresource.ContainerResource; +import io.quarkus.observability.devresource.DevResourceLifecycleManager; +import io.quarkus.observability.testcontainers.GrafanaContainer; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class GrafanaResource extends ContainerResource + implements QuarkusTestResourceLifecycleManager { + @Override + public GrafanaConfig config(ModulesConfiguration configuration) { + return configuration.grafana(); + } + + @Override + public GenericContainer container(GrafanaConfig config, ModulesConfiguration root) { + return set(new GrafanaContainer(config, root)); + } + + @Override + public Map config(int privatePort, String host, int publicPort) { + return Map.of("quarkus.grafana.url", String.format("%s:%s", host, publicPort)); + } + + @Override + protected GrafanaContainer defaultContainer() { + return new GrafanaContainer(); + } + + @Override + public Map doStart() { + String host = container.getHost(); + Integer mappedPort = container.getMappedPort(3000); + return Map.of("quarkus.grafana.url", String.format("%s:%s", host, mappedPort)); + } + + @Override + public int order() { + return DevResourceLifecycleManager.GRAFANA; + } +} diff --git a/extensions/observability/testlibs/devresource-grafana/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager b/extensions/observability/testlibs/devresource-grafana/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager new file mode 100644 index 00000000000000..ed897ce6df6e19 --- /dev/null +++ b/extensions/observability/testlibs/devresource-grafana/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager @@ -0,0 +1 @@ +io.quarkus.observability.devresource.grafana.GrafanaResource diff --git a/extensions/observability/testlibs/devresource-jaeger/pom.xml b/extensions/observability/testlibs/devresource-jaeger/pom.xml new file mode 100644 index 00000000000000..2311589305046f --- /dev/null +++ b/extensions/observability/testlibs/devresource-jaeger/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + quarkus-observability-testlibs + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-devresource-jaeger + Quarkus - Observability - Jaeger DevResource + Jaeger DevResource + + + true + + + + + io.quarkus + quarkus-observability-devresource + + + io.quarkus + quarkus-observability-testcontainers + + + io.quarkus + quarkus-test-common + + + + \ No newline at end of file diff --git a/extensions/observability/testlibs/devresource-jaeger/src/main/java/io/quarkus/observability/devresource/jaeger/JaegerResource.java b/extensions/observability/testlibs/devresource-jaeger/src/main/java/io/quarkus/observability/devresource/jaeger/JaegerResource.java new file mode 100644 index 00000000000000..e37c05edf3e550 --- /dev/null +++ b/extensions/observability/testlibs/devresource-jaeger/src/main/java/io/quarkus/observability/devresource/jaeger/JaegerResource.java @@ -0,0 +1,70 @@ +package io.quarkus.observability.devresource.jaeger; + +import java.util.Map; + +import org.jboss.logging.Logger; +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.observability.common.config.JaegerConfig; +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.observability.devresource.ContainerResource; +import io.quarkus.observability.devresource.DevResourceLifecycleManager; +import io.quarkus.observability.testcontainers.JaegerContainer; +import io.quarkus.runtime.configuration.ConfigUtils; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class JaegerResource extends ContainerResource + implements QuarkusTestResourceLifecycleManager { + + private static final Logger log = Logger.getLogger(JaegerResource.class); + + private static final String OTEL_CONFIG_ENDPOINT = "quarkus.otel.exporter.otlp.traces.endpoint"; + + @Override + public JaegerConfig config(ModulesConfiguration configuration) { + return configuration.jaeger(); + } + + @Override + public boolean enable() { + if (ConfigUtils.isPropertyPresent(OTEL_CONFIG_ENDPOINT)) { + log.debug("Not starting Dev Services for Jaeger as '" + OTEL_CONFIG_ENDPOINT + "' has been provided"); + return false; + } + return true; + } + + @Override + public GenericContainer container(JaegerConfig config) { + return set(new JaegerContainer(config)); + } + + @Override + public Map config(int privatePort, String host, int publicPort) { + switch (privatePort) { + case JaegerContainer.JAEGER_ENDPOINT_PORT: + return Map.of("quarkus.jaeger.endpoint", String.format("%s:%s", host, publicPort)); + case JaegerContainer.JAEGER_CONSOLE_PORT: + return Map.of("quarkus.jaeger.console", String.format("%s:%s", host, publicPort)); + } + return Map.of(); + } + + @Override + protected JaegerContainer defaultContainer() { + return new JaegerContainer(); + } + + @Override + public Map doStart() { + String host = container.getHost(); + return Map.of( + "quarkus.jaeger.endpoint", String.format("%s:%s", host, container.getJaegerEndpointPort()), + "quarkus.jaeger.console", String.format("%s:%s", host, container.getJaegerConsolePort())); + } + + @Override + public int order() { + return DevResourceLifecycleManager.JAEGER; + } +} diff --git a/extensions/observability/testlibs/devresource-jaeger/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager b/extensions/observability/testlibs/devresource-jaeger/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager new file mode 100644 index 00000000000000..4a34a30fb553a8 --- /dev/null +++ b/extensions/observability/testlibs/devresource-jaeger/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager @@ -0,0 +1 @@ +io.quarkus.observability.devresource.jaeger.JaegerResource diff --git a/extensions/observability/testlibs/devresource-otel-collector/pom.xml b/extensions/observability/testlibs/devresource-otel-collector/pom.xml new file mode 100644 index 00000000000000..28c9d5785b27e5 --- /dev/null +++ b/extensions/observability/testlibs/devresource-otel-collector/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + quarkus-observability-testlibs + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-devresource-otel-collector + Quarkus - Observability - OpenTelemetry Collector DevResource + OpenTelemetry Collector DevResource + + + true + + + + + io.quarkus + quarkus-observability-devresource + + + io.quarkus + quarkus-observability-testcontainers + + + io.quarkus + quarkus-test-common + + + + \ No newline at end of file diff --git a/extensions/observability/testlibs/devresource-otel-collector/src/main/java/io/quarkus/observability/devresource/otel/OTelCollectorResource.java b/extensions/observability/testlibs/devresource-otel-collector/src/main/java/io/quarkus/observability/devresource/otel/OTelCollectorResource.java new file mode 100644 index 00000000000000..a9cb980a067468 --- /dev/null +++ b/extensions/observability/testlibs/devresource-otel-collector/src/main/java/io/quarkus/observability/devresource/otel/OTelCollectorResource.java @@ -0,0 +1,54 @@ +package io.quarkus.observability.devresource.otel; + +import java.util.Map; + +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.observability.common.config.OTelConfig; +import io.quarkus.observability.devresource.ContainerResource; +import io.quarkus.observability.devresource.DevResourceLifecycleManager; +import io.quarkus.observability.testcontainers.OTelCollectorContainer; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class OTelCollectorResource extends ContainerResource + implements QuarkusTestResourceLifecycleManager { + @Override + public OTelConfig config(ModulesConfiguration configuration) { + return configuration.otel(); + } + + @Override + public GenericContainer container(OTelConfig config, ModulesConfiguration root) { + return set(new OTelCollectorContainer(config, root)); + } + + @Override + public Map config(int privatePort, String host, int publicPort) { + switch (privatePort) { + case OTelCollectorContainer.OTEL_GRPC_EXPORTER_PORT: + return Map.of("quarkus.otel-collector.grpc", String.format("%s:%s", host, publicPort)); + case OTelCollectorContainer.OTEL_HTTP_EXPORTER_PORT: + return Map.of("quarkus.otel-collector.http", String.format("%s:%s", host, publicPort)); + } + return Map.of(); + } + + @Override + protected OTelCollectorContainer defaultContainer() { + return new OTelCollectorContainer(); + } + + @Override + protected Map doStart() { + String host = container.getHost(); + return Map.of( + "quarkus.otel-collector.grpc", String.format("%s:%s", host, container.getOtelGrpcExporterPort()), + "quarkus.otel-collector.http", String.format("%s:%s", host, container.getOtelHttpExporterPort())); + } + + @Override + public int order() { + return DevResourceLifecycleManager.OTEL; + } +} diff --git a/extensions/observability/testlibs/devresource-otel-collector/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager b/extensions/observability/testlibs/devresource-otel-collector/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager new file mode 100644 index 00000000000000..2c8563962aa870 --- /dev/null +++ b/extensions/observability/testlibs/devresource-otel-collector/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager @@ -0,0 +1 @@ +io.quarkus.observability.devresource.otel.OTelCollectorResource diff --git a/extensions/observability/testlibs/devresource-victoriametrics/pom.xml b/extensions/observability/testlibs/devresource-victoriametrics/pom.xml new file mode 100644 index 00000000000000..1acd12150ec5f5 --- /dev/null +++ b/extensions/observability/testlibs/devresource-victoriametrics/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + quarkus-observability-testlibs + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-devresource-victoriametrics + Quarkus - Observability - Victoria Metrics DevResource + Victoria Metrics DevResource + + + true + + + + + io.quarkus + quarkus-observability-devresource + + + io.quarkus + quarkus-observability-testcontainers + + + io.quarkus + quarkus-test-common + + + + \ No newline at end of file diff --git a/extensions/observability/testlibs/devresource-victoriametrics/src/main/java/io/quarkus/observability/devresource/victoriametrics/VictoriaMetricsResource.java b/extensions/observability/testlibs/devresource-victoriametrics/src/main/java/io/quarkus/observability/devresource/victoriametrics/VictoriaMetricsResource.java new file mode 100644 index 00000000000000..d311e17f845fff --- /dev/null +++ b/extensions/observability/testlibs/devresource-victoriametrics/src/main/java/io/quarkus/observability/devresource/victoriametrics/VictoriaMetricsResource.java @@ -0,0 +1,65 @@ +package io.quarkus.observability.devresource.victoriametrics; + +import java.util.Map; + +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.observability.common.config.VictoriaMetricsConfig; +import io.quarkus.observability.devresource.ContainerResource; +import io.quarkus.observability.devresource.DevResourceLifecycleManager; +import io.quarkus.observability.testcontainers.VictoriaMetricsContainer; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class VictoriaMetricsResource extends ContainerResource + implements QuarkusTestResourceLifecycleManager { + + public VictoriaMetricsResource() { + } + + public VictoriaMetricsResource(int port, String dataPath) { + set(defaultContainer()) + .withMappedPort(port) + .withFileSystemBind(dataPath, "/victoria-metrics-data"); + } + + @Override + public VictoriaMetricsConfig config(ModulesConfiguration configuration) { + return configuration.victoriaMetrics(); + } + + @Override + public GenericContainer container(VictoriaMetricsConfig config) { + return set(new VictoriaMetricsContainer(config)); + } + + @Override + public Map config(int privatePort, String host, int publicPort) { + String endpoint = String.format("http://%s:%s", host, publicPort); + return config(endpoint); + } + + @Override + protected VictoriaMetricsContainer defaultContainer() { + return new VictoriaMetricsContainer(); + } + + @Override + protected Map doStart() { + String endpoint = container.getEndpoint(false); + return config(endpoint); + } + + @NotNull + private Map config(String endpoint) { + return Map.of( + "quarkus.rest-client.victoriametrics.url", endpoint, + "quarkus.rest-client.promql.url", endpoint); + } + + @Override + public int order() { + return DevResourceLifecycleManager.METRICS; + } +} diff --git a/extensions/observability/testlibs/devresource-victoriametrics/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager b/extensions/observability/testlibs/devresource-victoriametrics/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager new file mode 100644 index 00000000000000..32861c134f24eb --- /dev/null +++ b/extensions/observability/testlibs/devresource-victoriametrics/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager @@ -0,0 +1 @@ +io.quarkus.observability.devresource.victoriametrics.VictoriaMetricsResource diff --git a/extensions/observability/testlibs/devresource-vmagent/pom.xml b/extensions/observability/testlibs/devresource-vmagent/pom.xml new file mode 100644 index 00000000000000..bdc66b574db992 --- /dev/null +++ b/extensions/observability/testlibs/devresource-vmagent/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + quarkus-observability-testlibs + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-devresource-vmagent + Quarkus - Observability - Victoria Metrics Agent DevResource + Victoria Metrics Agent DevResource + + + true + + + + + io.quarkus + quarkus-observability-devresource + + + io.quarkus + quarkus-observability-testcontainers + + + io.quarkus + quarkus-test-common + + + + \ No newline at end of file diff --git a/extensions/observability/testlibs/devresource-vmagent/src/main/java/io/quarkus/observability/devresource/vmagent/VMAgentResource.java b/extensions/observability/testlibs/devresource-vmagent/src/main/java/io/quarkus/observability/devresource/vmagent/VMAgentResource.java new file mode 100644 index 00000000000000..803ee2d1c45516 --- /dev/null +++ b/extensions/observability/testlibs/devresource-vmagent/src/main/java/io/quarkus/observability/devresource/vmagent/VMAgentResource.java @@ -0,0 +1,71 @@ +package io.quarkus.observability.devresource.vmagent; + +import java.util.Map; + +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.observability.common.config.ConfigUtils; +import io.quarkus.observability.common.config.ModulesConfiguration; +import io.quarkus.observability.common.config.VMAgentConfig; +import io.quarkus.observability.devresource.ContainerResource; +import io.quarkus.observability.devresource.DevResourceLifecycleManager; +import io.quarkus.observability.testcontainers.VMAgentContainer; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class VMAgentResource extends ContainerResource + implements QuarkusTestResourceLifecycleManager { + + private Context context; + + @Override + public void setContext(Context context) { + this.context = context; + } + + protected int port() { + // if context == null, it was run from dev mode + return context != null || LaunchMode.current() == LaunchMode.TEST ? 8081 : 8080; + } + + @Override + public VMAgentConfig config(ModulesConfiguration configuration) { + return configuration.vmAgent(); + } + + @Override + public GenericContainer container(VMAgentConfig config, ModulesConfiguration root) { + String vmEndpoint = ConfigUtils.vmEndpoint(root.victoriaMetrics()); + return set(new VMAgentContainer(config, "http://" + vmEndpoint, config.scrapePort().orElse(port()))); + } + + @Override + public Map config(int privatePort, String host, int publicPort) { + return Map.of(); + } + + @Override + protected VMAgentContainer defaultContainer() { + return new VMAgentContainer("http://victoria-metrics:8428", port()); + } + + @Override + protected Map doStart() { + return Map.of(); + } + + @Override + public Map start() { + int port = port(); + Testcontainers.exposeHostPorts(port); + // TODO - url, VM vs Prometheus + return super.start(); + } + + @Override + public int order() { + return DevResourceLifecycleManager.SCRAPER; + } + +} diff --git a/extensions/observability/testlibs/devresource-vmagent/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager b/extensions/observability/testlibs/devresource-vmagent/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager new file mode 100644 index 00000000000000..c20872f059721a --- /dev/null +++ b/extensions/observability/testlibs/devresource-vmagent/src/main/resources/META-INF/services/io.quarkus.observability.devresource.DevResourceLifecycleManager @@ -0,0 +1 @@ +io.quarkus.observability.devresource.vmagent.VMAgentResource diff --git a/extensions/observability/testlibs/devresource/pom.xml b/extensions/observability/testlibs/devresource/pom.xml new file mode 100644 index 00000000000000..cf491f10cde2fb --- /dev/null +++ b/extensions/observability/testlibs/devresource/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + quarkus-observability-testlibs + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-devresource + Quarkus - Observability - DevResource + Simple DevResource abstraction + + + + org.eclipse.microprofile.config + microprofile-config-api + + + org.jboss.logging + jboss-logging + + + io.quarkus + quarkus-observability-common + + + org.testcontainers + testcontainers + + + junit + junit + + + + + io.quarkus + quarkus-junit4-mock + + + \ No newline at end of file diff --git a/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/ContainerResource.java b/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/ContainerResource.java new file mode 100644 index 00000000000000..f44581ed9c9a49 --- /dev/null +++ b/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/ContainerResource.java @@ -0,0 +1,41 @@ +package io.quarkus.observability.devresource; + +import java.util.Map; + +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.observability.common.config.ContainerConfig; + +/** + * A container resource abstraction + */ +public abstract class ContainerResource, C extends ContainerConfig> + implements DevResourceLifecycleManager { + + protected T container; + + protected T set(T container) { + this.container = container; + return container; + } + + @Override + public Map start() { + if (container == null) { + container = defaultContainer(); + } + container.start(); + return doStart(); + } + + @Override + public void stop() { + if (container != null) { + container.stop(); + } + } + + protected abstract T defaultContainer(); + + protected abstract Map doStart(); +} diff --git a/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResourceLifecycleManager.java b/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResourceLifecycleManager.java new file mode 100644 index 00000000000000..c315f3fa99adb2 --- /dev/null +++ b/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResourceLifecycleManager.java @@ -0,0 +1,102 @@ +package io.quarkus.observability.devresource; + +import java.util.Map; + +import org.testcontainers.containers.GenericContainer; + +import io.quarkus.observability.common.config.ContainerConfig; +import io.quarkus.observability.common.config.ModulesConfiguration; + +/** + * Compatible with {@link io.quarkus.test.common.QuarkusTestResourceLifecycleManager} + * so that classes can implement both interfaces at the same time. + */ +public interface DevResourceLifecycleManager { + + // Put order constants here -- order by dependency + + int METRICS = 5000; + int SCRAPER = 7500; + int GRAFANA = 10000; + int JAEGER = 20000; + int OTEL = 20000; + + //---- + + /** + * Get resource's config from main observability configuration. + * + * @param configuration main observability configuration + * @return module's config + */ + T config(ModulesConfiguration configuration); + + /** + * Should we enable / start this dev resource. + * e.g. we could already have actual service running + * Each impl should provide its own reason on why it disabled dev service. + * + * @return true if ok to start new dev service, false otherwise + */ + default boolean enable() { + return true; + } + + /** + * Create container from config. + * + * @param config the config + * @return container id + */ + default GenericContainer container(T config) { + throw new IllegalStateException("Should be implemented!"); + } + + /** + * Create container from config. + * + * @param config the config + * @param root the all modules config + * @return container id + */ + default GenericContainer container(T config, ModulesConfiguration root) { + return container(config); + } + + /** + * Deduct current config from params. + * + * @return A map of system properties that should be set for the running dev-mode app + */ + Map config(int privatePort, String host, int publicPort); + + /** + * Start the dev resource. + * + * @return A map of system properties that should be set for the running dev-mode app + */ + Map start(); + + /** + * Stop the dev resource. + */ + void stop(); + + /** + * Called even before {@link #start()} so that the implementation can prepare itself + * to be used as dev resource (as opposed to test resource which uses a different + * init() method). + */ + default void initDev() { + } + + /** + * If multiple dev resources are located, + * this control the order of which they will be executed. + * + * @return The order to be executed. The larger the number, the later the resource is invoked. + */ + default int order() { + return 0; + } +} diff --git a/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResources.java b/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResources.java new file mode 100644 index 00000000000000..0dbd79ed0579eb --- /dev/null +++ b/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResources.java @@ -0,0 +1,88 @@ +package io.quarkus.observability.devresource; + +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.stream.Collectors; + +import org.jboss.logging.Logger; + +/** + * A registry of dev resources. + */ +@SuppressWarnings("rawtypes") +public class DevResources { + private static final Logger log = Logger.getLogger(DevResources.class); + + private static List resources; + private static Map map; + + /** + * @return list of found dev resources. + */ + public static synchronized List resources() { + if (resources == null) { + log.info("Activating dev resources"); + + resources = ServiceLoader + .load(DevResourceLifecycleManager.class, Thread.currentThread().getContextClassLoader()) + .stream() + .map(ServiceLoader.Provider::get) + .sorted(Comparator.comparing(DevResourceLifecycleManager::order)) + .collect(Collectors.toList()); + + log.infof("Found dev resources: %s", resources); + } + return resources; + } + + /** + * Ensures all dev resources are started and returns a map of config properties. + * + * @return a map of config properties to be returned by {@link DevResourcesConfigSource} + */ + static synchronized Map ensureStarted() { + if (map == null) { + try { + for (var res : resources()) { + res.initDev(); + } + } catch (Exception e) { + log.error("Exception initializing dev resource manager", e); + throw e; + } + try { + var map = new HashMap(); + for (var res : resources()) { + var resMap = res.start(); + log.infof("Dev resource [%s] contributed config: %s", res.getClass().getSimpleName(), resMap); + map.putAll(resMap); + } + DevResources.map = Collections.unmodifiableMap(map); + } catch (Exception e) { + log.error("Exception starting dev resource", e); + throw e; + } + } + return map; + } + + /** + * Stops all dev resources. + */ + public static synchronized void stop() { + if (map != null) { + for (var i = resources().listIterator(resources().size()); i.hasPrevious();) { + try { + i.previous().stop(); + } catch (Exception e) { + log.warn("Exception stopping dev resource", e); + } + } + map = null; + } + } +} diff --git a/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResourcesConfigSource.java b/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResourcesConfigSource.java new file mode 100644 index 00000000000000..b3458c5be2be23 --- /dev/null +++ b/extensions/observability/testlibs/devresource/src/main/java/io/quarkus/observability/devresource/DevResourcesConfigSource.java @@ -0,0 +1,28 @@ +package io.quarkus.observability.devresource; + +import java.util.Set; + +import org.eclipse.microprofile.config.spi.ConfigSource; + +public class DevResourcesConfigSource implements ConfigSource { + @Override + public Set getPropertyNames() { + return DevResources.ensureStarted().keySet(); + } + + @Override + public String getValue(String propertyName) { + return DevResources.ensureStarted().get(propertyName); + } + + @Override + public String getName() { + return "DevResourcesConfigSource"; + } + + @Override + public int getOrdinal() { + // greater than any default Microprofile ConfigSource + return 500; + } +} diff --git a/extensions/observability/testlibs/pom.xml b/extensions/observability/testlibs/pom.xml new file mode 100644 index 00000000000000..cf85eb2b7c4eb9 --- /dev/null +++ b/extensions/observability/testlibs/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + quarkus-observability-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-testlibs + pom + Quarkus Observability - Test Libraries + Quarkus Observability - Test Libraries + + + devresource + devresource-victoriametrics + devresource-vmagent + devresource-grafana + devresource-otel-collector + devresource-jaeger + + + \ No newline at end of file diff --git a/extensions/observability/victoriametrics/pom.xml b/extensions/observability/victoriametrics/pom.xml new file mode 100644 index 00000000000000..86989c98856572 --- /dev/null +++ b/extensions/observability/victoriametrics/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + quarkus-observability-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-observability-victoriametrics + Quarkus - Observability - VictoriaMetrics client + VictoriaMetrics query language client + + + + + org.apache.maven.plugins + maven-compiler-plugin + + true + + + + + + + + + io.quarkus + quarkus-observability-promql + + + + io.prometheus + simpleclient + + + + io.prometheus + simpleclient_common + + + + + + io.quarkus + quarkus-observability-devresource-victoriametrics + test + + + + io.quarkus + quarkus-arc + test + + + + io.quarkus + quarkus-junit5 + test + + + + org.jboss.logmanager + jboss-logmanager-embedded + test + + + + + diff --git a/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/PushCollector.java b/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/PushCollector.java new file mode 100644 index 00000000000000..216f075eb4ca2f --- /dev/null +++ b/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/PushCollector.java @@ -0,0 +1,231 @@ +package io.quarkus.observability.victoriametrics.client; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.prometheus.client.Collector; +import io.prometheus.client.Counter; +import io.prometheus.client.Gauge; + +/** + * Similar to {@link io.prometheus.client.SimpleCollector} but meant to collect samples that + * can later be pushed rather than scraped. + */ +public abstract class PushCollector extends Collector { + protected final String fullname; + protected final String help; + protected final String unit; + protected final List labelNames; + + protected final Map, Child> children = new HashMap, Child>(); + protected Child noLabelsChild; + + /** + * Return the Child with the given labels, creating it if needed. + *

+ * Must be passed the same number of labels are were passed to {@link #labelNames}. + */ + public Child labels(String... labelValues) { + if (labelValues.length != labelNames.size()) { + throw new IllegalArgumentException("Incorrect number of labels."); + } + for (int i = 0; i < labelValues.length; i++) { + var label = labelValues[i]; + if (label == null) { + throw new IllegalArgumentException("Label '" + labelNames.get(i) + "' cannot be null."); + } + } + List key = Arrays.asList(labelValues); + Child c = children.get(key); + if (c != null) { + return c; + } + Child c2 = newChild(key); + Child tmp = children.putIfAbsent(key, c2); + return tmp == null ? c2 : tmp; + } + + /** + * Remove the Child with the given labels. + *

+ * Any references to the Child are invalidated. + */ + public void remove(String... labelValues) { + children.remove(Arrays.asList(labelValues)); + initializeNoLabelsChild(); + } + + /** + * Remove all children. + *

+ * Any references to any children are invalidated. + */ + public void clear() { + children.clear(); + initializeNoLabelsChild(); + } + + /** + * Initialize the child with no labels. + */ + protected void initializeNoLabelsChild() { + // Initialize metric if it has no labels. + if (labelNames.size() == 0) { + noLabelsChild = labels(); + } + } + + /** + * Replace the Child with the given labels. + *

+ * This is intended for advanced uses, in particular proxying metrics + * from another monitoring system. This allows for callbacks for returning + * values for {@link Counter} and {@link Gauge} without having to implement + * a full {@link Collector}. + *

+ * An example with {@link Gauge}: + * + *

+     * {@code
+     * Gauge.build().name("current_time").help("Current unixtime.").create()
+     *         .setChild(new Gauge.Child() {
+     *             public double get() {
+     *                 return System.currentTimeMillis() / MILLISECONDS_PER_SECOND;
+     *             }
+     *         }).register();
+     * }
+     * 
+ *

+ * Any references any previous Child with these labelValues are invalidated. + * A metric should be either all callbacks, or none. + */ + @SuppressWarnings("unchecked") + public T setChild(Child child, String... labelValues) { + if (labelValues.length != labelNames.size()) { + throw new IllegalArgumentException("Incorrect number of labels."); + } + children.put(Arrays.asList(labelValues), child); + return (T) this; + } + + /** + * Return a new child, workaround for Java generics limitations. + */ + protected abstract Child newChild(List labelValues); + + protected MetricFamilySamples familySamples(Type type, List samples) { + return new MetricFamilySamples(fullname, unit, type, help, samples); + } + + protected PushCollector(Builder b) { + if (b.name.isEmpty()) { + throw new IllegalStateException("Name hasn't been set."); + } + StringBuilder name = new StringBuilder(); + if (!b.namespace.isEmpty()) { + name.append(b.namespace).append('_'); + } + if (!b.subsystem.isEmpty()) { + name.append(b.subsystem).append('_'); + } + name.append(b.name); + unit = b.unit; + if (!unit.isEmpty()) { + name.append('_').append(unit); + } + fullname = name.toString(); + checkMetricName(fullname); + if (b.help != null && b.help.isEmpty()) { + throw new IllegalStateException("Help hasn't been set."); + } + help = b.help; + labelNames = Arrays.asList(b.labelNames); + + for (String n : labelNames) { + checkMetricLabelName(n); + } + + if (!b.dontInitializeNoLabelsChild) { + initializeNoLabelsChild(); + } + } + + /** + * Builders let you configure and then create collectors. + */ + public abstract static class Builder, C extends PushCollector> { + String namespace = ""; + String subsystem = ""; + String name = ""; + String unit = ""; + String help = ""; + String[] labelNames = new String[] {}; + // Some metrics require additional setup before the initialization can be done. + boolean dontInitializeNoLabelsChild; + + /** + * Set the name of the metric. Required. + */ + @SuppressWarnings("unchecked") + public B name(String name) { + this.name = name; + return (B) this; + } + + /** + * Set the subsystem of the metric. Optional. + */ + @SuppressWarnings("unchecked") + public B subsystem(String subsystem) { + this.subsystem = subsystem; + return (B) this; + } + + /** + * Set the namespace of the metric. Optional. + */ + @SuppressWarnings("unchecked") + public B namespace(String namespace) { + this.namespace = namespace; + return (B) this; + } + + /** + * Set the unit of the metric. Optional. + * + * @since 0.10.0 + */ + @SuppressWarnings("unchecked") + public B unit(String unit) { + this.unit = unit; + return (B) this; + } + + /** + * Set the help string of the metric. Required. + */ + @SuppressWarnings("unchecked") + public B help(String help) { + this.help = help; + return (B) this; + } + + /** + * Set the labelNames of the metric. Optional, defaults to no labels. + */ + @SuppressWarnings("unchecked") + public B labelNames(String... labelNames) { + this.labelNames = labelNames; + return (B) this; + } + + /** + * Return the constructed collector. + *

+ * Abstract due to generics limitations. + */ + public abstract C create(); + } +} diff --git a/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/PushGauge.java b/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/PushGauge.java new file mode 100644 index 00000000000000..7d1e08442d165d --- /dev/null +++ b/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/PushGauge.java @@ -0,0 +1,71 @@ +package io.quarkus.observability.victoriametrics.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Like {@link io.prometheus.client.Gauge} but meant to collect samples that + * can later be pushed rather than scraped. + */ +public class PushGauge extends PushCollector { + + private PushGauge(Builder b) { + super(b); + } + + public static class Builder extends PushCollector.Builder { + @Override + public PushGauge create() { + return new PushGauge(this); + } + } + + /** + * Return a Builder to allow configuration of a new PushGauge. Ensures required fields are provided. + * + * @param name The name of the metric + * @param help The help string of the metric + */ + public static Builder build(String name, String help) { + return new Builder().name(name).help(help); + } + + /** + * Return a Builder to allow configuration of a new Gauge. + */ + public static Builder build() { + return new Builder(); + } + + @Override + protected Child newChild(List labelValues) { + return new Child(labelValues); + } + + @Override + public List collect() { + var samples = children.values() + .stream() + .flatMap(child -> child.samples.stream()) + .collect(Collectors.toList()); + return List.of(familySamples(Type.GAUGE, samples)); + } + + public class Child { + private final List labelValues; + private final List samples = new ArrayList<>(); + + private Child(List labelValues) { + this.labelValues = labelValues; + } + + public void add(double value) { + samples.add(new MetricFamilySamples.Sample(fullname, labelNames, labelValues, value)); + } + + public void add(double value, long timestampMs) { + samples.add(new MetricFamilySamples.Sample(fullname, labelNames, labelValues, value, timestampMs)); + } + } +} diff --git a/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/Tag.java b/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/Tag.java new file mode 100644 index 00000000000000..c0cdc344aa32ff --- /dev/null +++ b/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/Tag.java @@ -0,0 +1,23 @@ +package io.quarkus.observability.victoriametrics.client; + +public class Tag { + private final String name; + private final String key; + + public Tag(String name, String key) { + this.name = name; + this.key = key; + } + + public static Tag of(String name, String key) { + return new Tag(name, key); + } + + public String getName() { + return name; + } + + public String getKey() { + return key; + } +} diff --git a/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/VictoriaMetricsService.java b/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/VictoriaMetricsService.java new file mode 100644 index 00000000000000..6e237bf553c131 --- /dev/null +++ b/extensions/observability/victoriametrics/src/main/java/io/quarkus/observability/victoriametrics/client/VictoriaMetricsService.java @@ -0,0 +1,96 @@ +package io.quarkus.observability.victoriametrics.client; + +import static io.prometheus.client.exporter.common.TextFormat.CONTENT_TYPE_OPENMETRICS_100; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.util.stream.Stream; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import io.prometheus.client.Collector; +import io.prometheus.client.exporter.common.TextFormat; +import io.quarkus.observability.promql.client.PromQLService; +import io.quarkus.observability.promql.client.rest.RequestDebugFilter; +import io.quarkus.observability.promql.client.rest.ResponseDebugFilter; +import io.quarkus.runtime.util.EnumerationUtil; + +/** + * VictoriaMetrics specific extension of {@link PromQLService}. + */ +@RegisterRestClient(configKey = "victoriametrics") +@RegisterProvider(RequestDebugFilter.class) +@RegisterProvider(ResponseDebugFilter.class) +public interface VictoriaMetricsService extends PromQLService { + + @POST + @Path("/api/v1/import/prometheus") + @Consumes(CONTENT_TYPE_OPENMETRICS_100) + void importPrometheus(String openmetricsText); + + static void importPrometheus(VictoriaMetricsService service, Stream collectors) { + var sw = new StringWriter(); + try { + TextFormat.writeFormat( + CONTENT_TYPE_OPENMETRICS_100, + sw, + EnumerationUtil.from(collectors.flatMap(c -> c.collect().stream()))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + service.importPrometheus(sw.toString()); + } + + /** + * Delete time series matching the given selector. Storage space for the deleted time series + * isn't freed instantly - it is freed during subsequent background merges of data files. + * Note that background merges may never occur for data from previous months, so storage space + * won't be freed for historical data. In this case forced merge may help to free up storage space. + * + * @param seriesSelector the selector for series to be deleted + */ + @POST + @Path("/api/v1/admin/tsdb/delete_series") + @Consumes("application/x-www-form-urlencoded") + void deleteSeries( + @FormParam("match[]") String seriesSelector); + + /** + * Triggers compaction (forced merge) for specified month partition. + * Returns immediately while compaction is performed in the background. + * + * @param partition the month partition to compact (force-merge) + * in format YYYY_MM + */ + @POST + @Path("/internal/force_merge") + @Consumes("application/x-www-form-urlencoded") + void compactMonthPartition( + @FormParam("partition_prefix") String partition); + + /** + * Data becomes available for querying in a few seconds after inserting. + * It is possible to flush in-memory buffers to persistent storage. + * This handler is mostly needed for testing and debugging purposes. + */ + @POST + @Path("/internal/force_flush") + void flush(); + + /** + * If you see gaps on the graphs, try resetting the cache. + * If this removes gaps on the graphs, then it is likely data with timestamps + * older than -search.cacheTimestampOffset is ingested into VictoriaMetrics. + * Make sure that data sources have synchronized time with VictoriaMetrics. + */ + @POST + @Path("/internal/resetRollupResultCache") + void resetRollupResultCache(); +} diff --git a/extensions/observability/victoriametrics/src/main/resources/META-INF/beans.xml b/extensions/observability/victoriametrics/src/main/resources/META-INF/beans.xml new file mode 100644 index 00000000000000..330c7f60e1bfda --- /dev/null +++ b/extensions/observability/victoriametrics/src/main/resources/META-INF/beans.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/observability/victoriametrics/src/test/java/io/quarkus/observability/victoriametrics/client/test/VictoriametricsConfiguration.java b/extensions/observability/victoriametrics/src/test/java/io/quarkus/observability/victoriametrics/client/test/VictoriametricsConfiguration.java new file mode 100644 index 00000000000000..2a9855d725bf28 --- /dev/null +++ b/extensions/observability/victoriametrics/src/test/java/io/quarkus/observability/victoriametrics/client/test/VictoriametricsConfiguration.java @@ -0,0 +1,16 @@ +package io.quarkus.observability.victoriametrics.client.test; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Singleton; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.observability.promql.client.util.ObservabilityObjectMapperFactory; + +@ApplicationScoped +public class VictoriametricsConfiguration { + @Singleton + public ObjectMapper objectMapper() { + return ObservabilityObjectMapperFactory.createObjectMapper(); + } +} diff --git a/extensions/observability/victoriametrics/src/test/java/io/quarkus/observability/victoriametrics/client/test/VictoriametricsTest.java b/extensions/observability/victoriametrics/src/test/java/io/quarkus/observability/victoriametrics/client/test/VictoriametricsTest.java new file mode 100644 index 00000000000000..8685ab7b3b231a --- /dev/null +++ b/extensions/observability/victoriametrics/src/test/java/io/quarkus/observability/victoriametrics/client/test/VictoriametricsTest.java @@ -0,0 +1,255 @@ +package io.quarkus.observability.victoriametrics.client.test; + +import java.io.UncheckedIOException; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Stream; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.observability.devresource.victoriametrics.VictoriaMetricsResource; +import io.quarkus.observability.promql.client.data.Dur; +import io.quarkus.observability.promql.client.data.LabelsResponse; +import io.quarkus.observability.promql.client.data.QueryResponse; +import io.quarkus.observability.promql.client.data.SeriesResponse; +import io.quarkus.observability.promql.client.data.Status; +import io.quarkus.observability.victoriametrics.client.PushGauge; +import io.quarkus.observability.victoriametrics.client.VictoriaMetricsService; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +@QuarkusTestResource(VictoriaMetricsResource.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class VictoriametricsTest { + private static final Logger log = LoggerFactory.getLogger(VictoriametricsTest.class); + + @Inject + @RestClient + VictoriaMetricsService vmService; + + @Inject + ObjectMapper objectMapper; + + final Instant now = Instant.now().with(ChronoField.NANO_OF_SECOND, 0); + final Instant minus1hour = now.minus(1, ChronoUnit.HOURS); + final Instant plus1hour = now.plus(1, ChronoUnit.HOURS); + + @BeforeAll + public void beforeAll() { + var height = PushGauge + .build() + .namespace("foo") + .subsystem("bar") + .name("height") + .help("foo-bar height in meters") + .unit("m") + .labelNames("instance", "module") + .create(); + + var width = PushGauge + .build() + .namespace("foo") + .subsystem("bar") + .name("width") + .help("foo-bar width in meters") + .unit("m") + .labelNames("instance", "module") + .create(); + + int i = 0; + for (var t = minus1hour; !t.isAfter(plus1hour); t = t.plus(3, ChronoUnit.MINUTES), i++) { + var ms = t.toEpochMilli(); + var r = ThreadLocalRandom.current().nextDouble(0, 0.01); + + if ((i % 2) > 0) { + log.info("Inserting heights for: {} ({} ms)", t, ms); + height.labels("a", "1").add(0.11 + r, ms); + height.labels("a", "2").add(0.12 + r, ms); + height.labels("b", "1").add(0.21 + r, ms); + height.labels("b", "2").add(0.22 + r, ms); + height.labels("c", "1").add(0.31 + r, ms); + height.labels("c", "2").add(0.32 + r, ms); + } + + if ((i % 3) > 0) { + log.info("Inserting widths for: {} ({} ms)", t, ms); + width.labels("a", "1").add(0.11 + r, ms); + width.labels("a", "2").add(0.12 + r, ms); + width.labels("b", "1").add(0.21 + r, ms); + width.labels("b", "2").add(0.22 + r, ms); + width.labels("c", "1").add(0.31 + r, ms); + width.labels("c", "2").add(0.32 + r, ms); + } + } + + VictoriaMetricsService.importPrometheus(vmService, Stream.of(height, width)); + vmService.flush(); + } + + @Test + @Order(1) + @Disabled("Due to resteasy bug not URL-escaping String query parameter(s) having {...} in them") + public void testGetLabels() { + LabelsResponse labelsResponse = vmService.getLabels( + "{__name__=~\".*\"}", + minus1hour, + plus1hour); + log.info("testGetLabels = {}", json(labelsResponse)); + } + + @Test + @Order(2) + public void testPostLabels() { + LabelsResponse labelsResponse = vmService.postLabels( + "{__name__=~\".*\"}", + minus1hour, + plus1hour); + log.info("testPostLabels = {}", json(labelsResponse)); + } + + @Test + @Order(3) + @Disabled("Due to resteasy bug not URL-escaping String query parameter(s) having {...} in them") + public void testGetLabelValues() { + LabelsResponse labelsResponse = vmService.getLabelValues( + "__name__", + "{__name__=~\".*\"}", + minus1hour, + plus1hour); + log.info("testGetLabelValues = {}", json(labelsResponse)); + } + + @Test + @Order(4) + public void testPostLabelValues() { + LabelsResponse labelsResponse = vmService.postLabelValues( + "__name__", + "{__name__=~\".*\"}", + minus1hour, + plus1hour); + log.info("testPostLabelValues = {}", json(labelsResponse)); + } + + @Test + @Order(5) + @Disabled("Due to resteasy bug not URL-escaping String query parameter(s) having {...} in them") + public void testGetSeries() { + SeriesResponse seriesResponse = vmService.getSeries( + "{__name__=~\"foo_bar_.*\"}", + minus1hour, + plus1hour); + log.info("testGetSeries = {}", json(seriesResponse)); + } + + @Test + @Order(6) + public void testPostSeries() { + SeriesResponse seriesResponse = vmService.postSeries( + "{__name__=~\"foo_bar_.*\"}", + minus1hour, + plus1hour); + log.info("testPostSeries = {}", json(seriesResponse)); + } + + @Test + @Order(7) + public void testGetInstantQuery() { + QueryResponse queryResponse = vmService.getInstantQuery( + "foo_bar_height_m{} + foo_bar_width_m{}", + now, + new Dur(Duration.ofMinutes(1))); + log.info("testGetInstantQuery = {}", json(queryResponse)); + } + + @Test + @Order(8) + public void testPostInstantQuery() { + QueryResponse queryResponse = vmService.postInstantQuery( + "foo_bar_height_m{} + foo_bar_width_m{}", + now, + new Dur(Duration.ofMinutes(1))); + log.info("testPostInstantQuery = {}", json(queryResponse)); + } + + @Test + @Order(9) + public void testGetRangeQuery() { + QueryResponse queryResponse = vmService.getRangeQuery( + "foo_bar_height_m{} + foo_bar_width_m{}", + minus1hour, + plus1hour, + new Dur(Duration.ofMinutes(5)), + new Dur(Duration.ofMinutes(1))); + log.info("testGetRangeQuery = {}", json(queryResponse)); + } + + @Test + @Order(10) + public void testPostRangeQuery1() { + QueryResponse queryResponse = vmService.postRangeQuery( + "foo_bar_height_m{} + foo_bar_width_m{}", + minus1hour, + plus1hour, + new Dur(Duration.ofMinutes(5)), + new Dur(Duration.ofMinutes(1))); + log.info("testPostRangeQuery1 = {}", json(queryResponse)); + } + + @Test + @Order(10) + public void testPostRangeQuery2() { + QueryResponse queryResponse = vmService.postRangeQuery( + "{__name__=~\"foo_bar_.*\"}", + minus1hour, + plus1hour, + new Dur(Duration.ofMinutes(15)), + new Dur(Duration.ofMinutes(1))); + log.info("testPostRangeQuery2 = {}", json(queryResponse)); + } + + @Test + @Order(11) + public void testDeleteSeries() throws Exception { + var selector = "{__name__=~\"foo_bar_.*\"}"; + log.info("postSeries({})...", selector); + var series = vmService.postSeries(selector, minus1hour, plus1hour); + Assertions.assertEquals(Status.success, series.status()); + series.data().forEach(m -> log.info("{}", json(m))); + log.info("Deleting it..."); + vmService.deleteSeries(selector); + series = vmService.postSeries(selector, minus1hour, plus1hour); + log.info("postSeries({})...", selector); + series.data().forEach(m -> log.info("{}", json(m))); + log.info("...done."); + Assertions.assertEquals(List.of(), series.data()); + } + + private String json(Object o) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(o); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/extensions/pom.xml b/extensions/pom.xml index 5734b8bde14df6..f599023ff67b64 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -54,6 +54,7 @@ micrometer-registry-prometheus opentelemetry info + observability resteasy-classic diff --git a/integration-tests/observability-multiapp/pom.xml b/integration-tests/observability-multiapp/pom.xml new file mode 100644 index 00000000000000..9b52af32c22907 --- /dev/null +++ b/integration-tests/observability-multiapp/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-observability-multiapp + Quarkus - Integration Tests - Observability MultiApp + + + + io.quarkus + quarkus-observability + + + io.quarkus + quarkus-resteasy-reactive + + + io.quarkus + quarkus-rest-client + + + + io.quarkiverse.micrometer.registry + quarkus-micrometer-registry-otlp + 3.2.4 + + + io.quarkus + quarkus-observability-devresource-otel-collector + + + io.quarkus + quarkus-observability-devresource-jaeger + + + io.quarkus + quarkus-observability-devresource-victoriametrics + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + org.assertj + assertj-core + test + + + + + io.quarkus + quarkus-observability-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-resteasy-reactive-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-rest-client-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + diff --git a/integration-tests/observability-multiapp/src/main/java/io/quarkus/observability/example/SimpleClient.java b/integration-tests/observability-multiapp/src/main/java/io/quarkus/observability/example/SimpleClient.java new file mode 100644 index 00000000000000..21c447ad950b1f --- /dev/null +++ b/integration-tests/observability-multiapp/src/main/java/io/quarkus/observability/example/SimpleClient.java @@ -0,0 +1,14 @@ +package io.quarkus.observability.example; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(configKey = "simple") +public interface SimpleClient { + @GET + @Path("/api/poke") + public String poke(@QueryParam("f") int f); +} diff --git a/integration-tests/observability-multiapp/src/main/java/io/quarkus/observability/example/SimpleEndpoint.java b/integration-tests/observability-multiapp/src/main/java/io/quarkus/observability/example/SimpleEndpoint.java new file mode 100644 index 00000000000000..5d372625123b1d --- /dev/null +++ b/integration-tests/observability-multiapp/src/main/java/io/quarkus/observability/example/SimpleEndpoint.java @@ -0,0 +1,58 @@ +package io.quarkus.observability.example; + +import java.security.SecureRandom; +import java.util.Random; + +import jakarta.annotation.PostConstruct; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; + +@Path("/api") +public class SimpleEndpoint { + private static final Logger log = Logger.getLogger(SimpleEndpoint.class); + + @Inject + MeterRegistry registry; + + @Inject + @RestClient + SimpleClient client; + + Random random = new SecureRandom(); + double[] arr = new double[1]; + + @PostConstruct + public void start() { + String key = System.getProperty("tag-key", "test"); + Gauge.builder("xvalue", arr, a -> arr[0]) + .baseUnit("X") + .description("Some random x") + .tag(key, "x") + .register(registry); + } + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Path("/poke") + public String poke(@QueryParam("f") int f) { + log.infof("Poke %s", f); + double x = random.nextDouble() * f; + arr[0] = x; + + if (f > 0) { + return client.poke(0); + } else { + return "poke:" + x; + } + } +} diff --git a/integration-tests/observability-multiapp/src/main/resources/application.properties b/integration-tests/observability-multiapp/src/main/resources/application.properties new file mode 100644 index 00000000000000..43e965b0443dbf --- /dev/null +++ b/integration-tests/observability-multiapp/src/main/resources/application.properties @@ -0,0 +1,12 @@ +# Disable default binders +quarkus.micrometer.binder-enabled-default=false + +quarkus.log.category."io.quarkus.observability".level=DEBUG + +#micrometer +quarkus.micrometer.export.otlp.enabled=true +quarkus.micrometer.export.otlp.publish=true +quarkus.micrometer.export.otlp.default-registry=true +quarkus.micrometer.export.otlp.url=http://${quarkus.otel-collector.http}/v1/metrics + +quarkus.rest-client.simple.url=http://localhost:8082 \ No newline at end of file diff --git a/integration-tests/observability-multiapp/src/test/java/io/quarkus/observability/test/SharedTracingTest.java b/integration-tests/observability-multiapp/src/test/java/io/quarkus/observability/test/SharedTracingTest.java new file mode 100644 index 00000000000000..9fab65709c7559 --- /dev/null +++ b/integration-tests/observability-multiapp/src/test/java/io/quarkus/observability/test/SharedTracingTest.java @@ -0,0 +1,14 @@ +package io.quarkus.observability.test; + +import org.junit.jupiter.api.Test; + +import io.restassured.RestAssured; + +public class SharedTracingTest { + + @Test + public void testTracing() throws Exception { + String response = RestAssured.get("/api/poke?f=100").body().asString(); + } + +} diff --git a/integration-tests/observability/pom.xml b/integration-tests/observability/pom.xml new file mode 100644 index 00000000000000..8fd124cc49e774 --- /dev/null +++ b/integration-tests/observability/pom.xml @@ -0,0 +1,159 @@ + + + 4.0.0 + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-observability + Quarkus - Integration Tests - Observability + + + + io.quarkus + quarkus-observability + + + io.quarkus + quarkus-resteasy-reactive + + + io.quarkus + quarkus-observability-victoriametrics + + + + io.quarkus + quarkus-observability-devresource-victoriametrics + + + io.quarkus + quarkus-observability-devresource-grafana + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + org.assertj + assertj-core + test + + + + + io.quarkus + quarkus-observability-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-micrometer-registry-prometheus-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-resteasy-reactive-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + + default + + true + + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + io.quarkus + quarkus-observability-devresource-vmagent + + + + + otel + + + quarkus.profile + otel + + + + + io.quarkiverse.micrometer.registry + quarkus-micrometer-registry-otlp + 3.2.4 + + + io.quarkus + quarkus-observability-devresource-otel-collector + + + io.quarkus + quarkus-observability-devresource-jaeger + + + + + diff --git a/integration-tests/observability/src/main/java/io/quarkus/observability/example/SimpleEndpoint.java b/integration-tests/observability/src/main/java/io/quarkus/observability/example/SimpleEndpoint.java new file mode 100644 index 00000000000000..78e2b7adc60067 --- /dev/null +++ b/integration-tests/observability/src/main/java/io/quarkus/observability/example/SimpleEndpoint.java @@ -0,0 +1,47 @@ +package io.quarkus.observability.example; + +import java.security.SecureRandom; +import java.util.Random; + +import jakarta.annotation.PostConstruct; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +import org.jboss.logging.Logger; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; + +@Path("/api") +public class SimpleEndpoint { + private static final Logger log = Logger.getLogger(SimpleEndpoint.class); + + @Inject + MeterRegistry registry; + + Random random = new SecureRandom(); + double[] arr = new double[1]; + + @PostConstruct + public void start() { + Gauge.builder("xvalue", arr, a -> arr[0]) + .baseUnit("X") + .description("Some random x") + .tag("test", "x") + .register(registry); + } + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Path("/poke") + public String poke(@QueryParam("f") int f) { + log.infof("Poke %s", f); + double x = random.nextDouble() * f; + arr[0] = x; + return "poke:" + x; + } +} diff --git a/integration-tests/observability/src/main/java/io/quarkus/observability/example/VmEndpoint.java b/integration-tests/observability/src/main/java/io/quarkus/observability/example/VmEndpoint.java new file mode 100644 index 00000000000000..37199571660c6c --- /dev/null +++ b/integration-tests/observability/src/main/java/io/quarkus/observability/example/VmEndpoint.java @@ -0,0 +1,55 @@ +package io.quarkus.observability.example; + +import java.security.SecureRandom; +import java.util.Random; +import java.util.stream.Stream; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; + +import io.quarkus.observability.victoriametrics.client.PushGauge; +import io.quarkus.observability.victoriametrics.client.VictoriaMetricsService; + +@Path("/vm") +public class VmEndpoint { + private static final Logger log = Logger.getLogger(VmEndpoint.class); + + @Inject + @RestClient + VictoriaMetricsService service; + + Random random = new SecureRandom(); + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Path("/poke") + public String poke(@QueryParam("f") int f) { + log.infof("Poke %s", f); + double x = random.nextDouble() * f; + + PushGauge gauge = PushGauge + .build() + .namespace("quarkus") + .subsystem("observability") + .name("xvalue") + .help("Some random x") + .unit("X") + .labelNames("test") + .create(); + + gauge + .labels("x") + .add(x); + + VictoriaMetricsService.importPrometheus(service, Stream.of(gauge)); + + return "poke:" + x; + } +} diff --git a/integration-tests/observability/src/main/resources/application.properties b/integration-tests/observability/src/main/resources/application.properties new file mode 100644 index 00000000000000..f901c6ddeb2407 --- /dev/null +++ b/integration-tests/observability/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# Disable default binders +quarkus.micrometer.binder-enabled-default=false + +quarkus.log.category."io.quarkus.observability".level=DEBUG + +# disable grafana for the tests +%test.quarkus.observability.grafana.enabled=false + +#micrometer +%otel.quarkus.micrometer.export.otlp.enabled=true +%otel.quarkus.micrometer.export.otlp.publish=true +%otel.quarkus.micrometer.export.otlp.default-registry=true +%otel.quarkus.micrometer.export.otlp.url=http://${quarkus.otel-collector.http}/v1/metrics \ No newline at end of file diff --git a/integration-tests/observability/src/test/java/io/quarkus/observability/test/DevResourcesMetricsTest.java b/integration-tests/observability/src/test/java/io/quarkus/observability/test/DevResourcesMetricsTest.java new file mode 100644 index 00000000000000..91abd406da6949 --- /dev/null +++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/DevResourcesMetricsTest.java @@ -0,0 +1,10 @@ +package io.quarkus.observability.test; + +import io.quarkus.observability.test.support.DevResourcesTestProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(DevResourcesTestProfile.class) +public class DevResourcesMetricsTest extends MetricsTestBase { +} diff --git a/integration-tests/observability/src/test/java/io/quarkus/observability/test/DevServicesMetricsTest.java b/integration-tests/observability/src/test/java/io/quarkus/observability/test/DevServicesMetricsTest.java new file mode 100644 index 00000000000000..82a2c544c3311e --- /dev/null +++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/DevServicesMetricsTest.java @@ -0,0 +1,7 @@ +package io.quarkus.observability.test; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +public class DevServicesMetricsTest extends MetricsTestBase { +} diff --git a/integration-tests/observability/src/test/java/io/quarkus/observability/test/MetricsTestBase.java b/integration-tests/observability/src/test/java/io/quarkus/observability/test/MetricsTestBase.java new file mode 100644 index 00000000000000..49369834270d94 --- /dev/null +++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/MetricsTestBase.java @@ -0,0 +1,50 @@ +package io.quarkus.observability.test; + +import java.time.Instant; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.quarkus.observability.promql.client.PromQLService; +import io.quarkus.observability.promql.client.data.LabelsResponse; +import io.restassured.RestAssured; + +public abstract class MetricsTestBase { + + @Inject + @RestClient + PromQLService service; + + final Instant now = Instant.now().with(ChronoField.NANO_OF_SECOND, 0); + final Instant minus1hour = now.minus(1, ChronoUnit.HOURS); + final Instant plus1hour = now.plus(1, ChronoUnit.HOURS); + + protected String path() { + return "/api"; + } + + @Test + public void testScrapeAndQuery() throws Exception { + String response = RestAssured.get(path() + "/poke?f=100").body().asString(); + Assertions.assertTrue(response.startsWith("poke:"), response); + + Thread.sleep(30_000); // wait 30sec to scrape ... + + LabelsResponse labelsResponse = service.postLabelValues( + "__name__", + "{__name__=~\".*\"}", + minus1hour, + plus1hour); + List data = labelsResponse.data(); + Assertions.assertFalse(data.isEmpty(), "Empty data"); // should be some data + boolean eXists = data.stream().anyMatch(d -> d.contains("xvalue_X")); // X is on purpose ;-) + Assertions.assertTrue(eXists, "No test metrics"); // find the gauge + } + +} diff --git a/integration-tests/observability/src/test/java/io/quarkus/observability/test/QuarkusTestResourceMetricsTest.java b/integration-tests/observability/src/test/java/io/quarkus/observability/test/QuarkusTestResourceMetricsTest.java new file mode 100644 index 00000000000000..322ec6a0833118 --- /dev/null +++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/QuarkusTestResourceMetricsTest.java @@ -0,0 +1,16 @@ +package io.quarkus.observability.test; + +import io.quarkus.observability.devresource.victoriametrics.VictoriaMetricsResource; +import io.quarkus.observability.devresource.vmagent.VMAgentResource; +import io.quarkus.observability.test.support.QuarkusTestResourceTestProfile; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@QuarkusTestResource.List({ + @QuarkusTestResource(value = VictoriaMetricsResource.class, restrictToAnnotatedClass = true), + @QuarkusTestResource(value = VMAgentResource.class, restrictToAnnotatedClass = true) }) +@TestProfile(QuarkusTestResourceTestProfile.class) +public class QuarkusTestResourceMetricsTest extends MetricsTestBase { +} diff --git a/integration-tests/observability/src/test/java/io/quarkus/observability/test/VmMetricsTest.java b/integration-tests/observability/src/test/java/io/quarkus/observability/test/VmMetricsTest.java new file mode 100644 index 00000000000000..bcc8320b0c603f --- /dev/null +++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/VmMetricsTest.java @@ -0,0 +1,14 @@ +package io.quarkus.observability.test; + +import io.quarkus.observability.test.support.VmTestProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(VmTestProfile.class) +public class VmMetricsTest extends MetricsTestBase { + @Override + protected String path() { + return "/vm"; + } +} diff --git a/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/DevResourcesTestProfile.java b/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/DevResourcesTestProfile.java new file mode 100644 index 00000000000000..1110a110730984 --- /dev/null +++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/DevResourcesTestProfile.java @@ -0,0 +1,14 @@ +package io.quarkus.observability.test.support; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public class DevResourcesTestProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.observability.dev-resources", "true", + "quarkus.observability.enabled", "false"); + } +} diff --git a/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/QuarkusTestResourceTestProfile.java b/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/QuarkusTestResourceTestProfile.java new file mode 100644 index 00000000000000..b60772d61d550c --- /dev/null +++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/QuarkusTestResourceTestProfile.java @@ -0,0 +1,14 @@ +package io.quarkus.observability.test.support; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public class QuarkusTestResourceTestProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.observability.dev-resources", "false", + "quarkus.observability.enabled", "false"); + } +} diff --git a/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/VmTestProfile.java b/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/VmTestProfile.java new file mode 100644 index 00000000000000..a228e71c039106 --- /dev/null +++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/support/VmTestProfile.java @@ -0,0 +1,12 @@ +package io.quarkus.observability.test.support; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public class VmTestProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("quarkus.observability.vm-agent.enabled", "false"); + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 4ffed18b7049f5..a0eba0cd9041cb 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -257,6 +257,8 @@ flyway liquibase liquibase-mongodb + observability + observability-multiapp oidc oidc-client oidc-client-reactive