> 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 0000000000000..627cdcdd01d8b
--- /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 0000000000000..d6bb59e981d58
--- /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 0000000000000..001da8a94f44d
--- /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 0000000000000..4b5a97d6bf9dc
--- /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 0000000000000..7407d3ba9a3ce
--- /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 0000000000000..a968f4ce47b1d
--- /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 0000000000000..99a831193b297
--- /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 0000000000000..9867c23786137
--- /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 0000000000000..b35b566689767
--- /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 0000000000000..db6fa33639f91
--- /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 0000000000000..4bde7af481a59
--- /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 0000000000000..88d17c7c34d13
--- /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 0000000000000..e12f5133c89cc
--- /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 0000000000000..5c277034ce3b6
--- /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 0000000000000..bc2bb2bdabeee
--- /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 0000000000000..0db4542056051
--- /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 0000000000000..ec161f8aa3840
--- /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 0000000000000..fd51133b73cd5
--- /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 0000000000000..029cce6e93a2e
--- /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 0000000000000..2f41fe50b0fb2
--- /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 0000000000000..19c8c338028d0
--- /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 0000000000000..597fa13783c8b
--- /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 0000000000000..38688d85a344f
--- /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 0000000000000..0e478188578ec
--- /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 0000000000000..ad48596d068b5
--- /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 0000000000000..1fe2c59049565
--- /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 0000000000000..9f66daf44d9b6
--- /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 0000000000000..d528cb817ad44
--- /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 0000000000000..2698e2e877a29
--- /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 0000000000000..b3e8a0a845c90
--- /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 0000000000000..b702a529a490d
--- /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 0000000000000..f3b7aad0dedc4
--- /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 0000000000000..0940b09432d17
--- /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 0000000000000..9fb96708c59f3
--- /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 0000000000000..2b142ebba564b
--- /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 0000000000000..b73f5a81f9456
--- /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 0000000000000..6aa3a568130d9
--- /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 0000000000000..d90e607155b68
--- /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 0000000000000..200978e87a1be
--- /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 0000000000000..e36041e87f714
--- /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 0000000000000..330c7f60e1bfd
--- /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 0000000000000..f26af25d19d8b
--- /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 0000000000000..a3d35bc7f068f
--- /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 0000000000000..d72b431dd5ab3
--- /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 0000000000000..4618cbd09571d
--- /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 0000000000000..864f7a41ef89f
--- /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 0000000000000..8c9d8491ff066
--- /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 0000000000000..1ba54c2955aaa
--- /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 0000000000000..5b373b8291982
--- /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 0000000000000..0d63c09b081dc
--- /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 0000000000000..8d327a1d4bb0c
--- /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 0000000000000..e9327be656c1e
--- /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 0000000000000..cd7f63da12e32
--- /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 0000000000000..f08c3ad38cbf8
--- /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 0000000000000..41529d8bbec66
--- /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 0000000000000..5e8b4b9555902
--- /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 static io.quarkus.runtime.configuration.ConfigUtils.getFirstOptionalValue;
+
+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;
+
+@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 0000000000000..21ae98aafafa1
--- /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 0000000000000..a7fb0f37df92e
--- /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 0000000000000..965985fe52608
--- /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 0000000000000..6f9451f8b9a85
--- /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 0000000000000..152a56386eafb
--- /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 0000000000000..321168e6367a3
--- /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 0000000000000..42b3d1658a5a0
--- /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 0000000000000..573dbd52d2643
--- /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 0000000000000..1df75f8a51b0e
--- /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 0000000000000..d87f3983a13c4
--- /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 0000000000000..ed897ce6df6e1
--- /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 0000000000000..2311589305046
--- /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 0000000000000..e37c05edf3e55
--- /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 0000000000000..4a34a30fb553a
--- /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 0000000000000..28c9d5785b27e
--- /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 0000000000000..a9cb980a06746
--- /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 0000000000000..2c8563962aa87
--- /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 0000000000000..1acd12150ec5f
--- /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 0000000000000..d311e17f845ff
--- /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 0000000000000..32861c134f24e
--- /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 0000000000000..bdc66b574db99
--- /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 0000000000000..803ee2d1c4551
--- /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 0000000000000..c20872f059721
--- /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 0000000000000..cf491f10cde2f
--- /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 0000000000000..f44581ed9c9a4
--- /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 0000000000000..c315f3fa99adb
--- /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 0000000000000..0dbd79ed0579e
--- /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 0000000000000..b3458c5be2be2
--- /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 0000000000000..cf85eb2b7c4eb
--- /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 0000000000000..86989c9885657
--- /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 0000000000000..216f075eb4ca2
--- /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 0000000000000..7d1e08442d165
--- /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 0000000000000..c0cdc344aa32f
--- /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 0000000000000..6e237bf553c13
--- /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 extends Collector> 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 0000000000000..330c7f60e1bfd
--- /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 0000000000000..2a9855d725bf2
--- /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 0000000000000..59d842b56205e
--- /dev/null
+++ b/extensions/observability/victoriametrics/src/test/java/io/quarkus/observability/victoriametrics/client/test/VictoriametricsTest.java
@@ -0,0 +1,258 @@
+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.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+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)
+@DisabledOnOs(OS.WINDOWS)
+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 f405528ad1782..fefd6472421a0 100644
--- a/extensions/pom.xml
+++ b/extensions/pom.xml
@@ -52,6 +52,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 0000000000000..9b52af32c2290
--- /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 0000000000000..21c447ad950b1
--- /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 0000000000000..5d372625123b1
--- /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 0000000000000..43e965b0443db
--- /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 0000000000000..36b0f1b3d9db4
--- /dev/null
+++ b/integration-tests/observability-multiapp/src/test/java/io/quarkus/observability/test/SharedTracingTest.java
@@ -0,0 +1,17 @@
+package io.quarkus.observability.test;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+import io.restassured.RestAssured;
+
+@DisabledOnOs(OS.WINDOWS)
+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 0000000000000..8fd124cc49e77
--- /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 0000000000000..78e2b7adc6006
--- /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 0000000000000..37199571660c6
--- /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 0000000000000..f901c6ddeb240
--- /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 0000000000000..13a0077dfa46f
--- /dev/null
+++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/DevResourcesMetricsTest.java
@@ -0,0 +1,14 @@
+package io.quarkus.observability.test;
+
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+import io.quarkus.observability.test.support.DevResourcesTestProfile;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.TestProfile;
+
+@QuarkusTest
+@TestProfile(DevResourcesTestProfile.class)
+@DisabledOnOs(OS.WINDOWS)
+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 0000000000000..90c141faebf7d
--- /dev/null
+++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/DevServicesMetricsTest.java
@@ -0,0 +1,11 @@
+package io.quarkus.observability.test;
+
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+import io.quarkus.test.junit.QuarkusTest;
+
+@QuarkusTest
+@DisabledOnOs(OS.WINDOWS)
+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 0000000000000..49369834270d9
--- /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 0000000000000..241c039542563
--- /dev/null
+++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/QuarkusTestResourceMetricsTest.java
@@ -0,0 +1,20 @@
+package io.quarkus.observability.test;
+
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+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)
+@DisabledOnOs(OS.WINDOWS)
+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 0000000000000..e7293d77b6a75
--- /dev/null
+++ b/integration-tests/observability/src/test/java/io/quarkus/observability/test/VmMetricsTest.java
@@ -0,0 +1,18 @@
+package io.quarkus.observability.test;
+
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+import io.quarkus.observability.test.support.VmTestProfile;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.TestProfile;
+
+@QuarkusTest
+@TestProfile(VmTestProfile.class)
+@DisabledOnOs(OS.WINDOWS)
+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 0000000000000..1110a11073098
--- /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 0000000000000..b60772d61d550
--- /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 0000000000000..a228e71c03910
--- /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 ad79ab03ce3c6..2005ebf9d8674 100644
--- a/integration-tests/pom.xml
+++ b/integration-tests/pom.xml
@@ -276,6 +276,8 @@
flyway
liquibase
liquibase-mongodb
+ observability
+ observability-multiapp
oidc
oidc-client
oidc-client-reactive