From 8d89826f2e7579f20c4acf39fa0c3d3dbc510df9 Mon Sep 17 00:00:00 2001 From: brunobat Date: Fri, 11 Oct 2024 15:13:14 +0100 Subject: [PATCH] Micrometer to OTel Bridge --- .github/native-tests.json | 2 +- bom/application/pom.xml | 10 + docs/pom.xml | 13 + ...telemetry-micrometer-to-opentelemetry.adoc | 219 ++++++++++++ .../deployment/pom.xml | 134 ++++++++ ...eterOTelBridgeConfigBuilderCustomizer.java | 21 ++ .../MicrometerOtelBridgeProcessor.java | 75 +++++ ...rye.config.SmallRyeConfigBuilderCustomizer | 1 + .../deployment/DistributionSummaryTest.java | 129 +++++++ .../deployment/MetricsDisabledTest.java | 77 +++++ .../deployment/common/CountedResource.java | 64 ++++ .../deployment/common/GuardedResult.java | 34 ++ .../deployment/common/HelloResource.java | 23 ++ .../common/InMemoryMetricExporter.java | 181 ++++++++++ .../InMemoryMetricExporterProvider.java | 19 ++ .../deployment/common/MetricDataFilter.java | 247 ++++++++++++++ .../deployment/common/PingPongResource.java | 75 +++++ .../deployment/common/ServletEndpoint.java | 18 + .../deployment/common/TimedResource.java | 66 ++++ .../opentelemetry/deployment/common/Util.java | 75 +++++ .../deployment/common/VertxWebEndpoint.java | 24 ++ .../compatibility/HttpCompatibilityTest.java | 235 +++++++++++++ .../compatibility/JvmCompatibilityTest.java | 105 ++++++ .../MicrometerCounterInterceptorTest.java | 125 +++++++ .../MicrometerTimedInterceptorTest.java | 315 ++++++++++++++++++ .../RestClientUriParameterTest.java | 94 ++++++ .../src/test/resources/application.properties | 0 .../test/resources/test-logging.properties | 4 + extensions/micrometer-opentelemetry/pom.xml | 23 ++ .../micrometer-opentelemetry/runtime/pom.xml | 76 +++++ .../runtime/MicrometerOtelBridgeRecorder.java | 48 +++ .../resources/META-INF/quarkus-extension.yaml | 17 + .../src/main/resources/application.properties | 3 + .../deployment/metric/MetricProcessor.java | 8 +- extensions/pom.xml | 1 + .../micrometer-opentelemetry/pom.xml | 203 +++++++++++ .../micrometer/opentelemetry/AppResource.java | 26 ++ .../opentelemetry/ExporterResource.java | 125 +++++++ .../opentelemetry/SimpleResource.java | 163 +++++++++ .../opentelemetry/services/CountedBean.java | 27 ++ .../services/ManualHistogram.java | 28 ++ .../services/TestValueResolver.java | 13 + .../opentelemetry/services/TraceData.java | 5 + .../opentelemetry/services/TracedService.java | 20 ++ .../src/main/resources/application.properties | 11 + .../MicrometerCounterInterceptorTest.java | 63 ++++ integration-tests/pom.xml | 1 + 47 files changed, 3243 insertions(+), 3 deletions(-) create mode 100644 docs/src/main/asciidoc/telemetry-micrometer-to-opentelemetry.adoc create mode 100644 extensions/micrometer-opentelemetry/deployment/pom.xml create mode 100644 extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOTelBridgeConfigBuilderCustomizer.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/main/resources/META-INF/services/io.smallrye.config.SmallRyeConfigBuilderCustomizer create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/DistributionSummaryTest.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/MetricsDisabledTest.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/CountedResource.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/GuardedResult.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/HelloResource.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporter.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporterProvider.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/MetricDataFilter.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/PingPongResource.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/ServletEndpoint.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/TimedResource.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/Util.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/VertxWebEndpoint.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/HttpCompatibilityTest.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/JvmCompatibilityTest.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerCounterInterceptorTest.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerTimedInterceptorTest.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/RestClientUriParameterTest.java create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/resources/application.properties create mode 100644 extensions/micrometer-opentelemetry/deployment/src/test/resources/test-logging.properties create mode 100644 extensions/micrometer-opentelemetry/pom.xml create mode 100644 extensions/micrometer-opentelemetry/runtime/pom.xml create mode 100644 extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java create mode 100644 extensions/micrometer-opentelemetry/runtime/src/main/resources/META-INF/quarkus-extension.yaml create mode 100644 extensions/micrometer-opentelemetry/runtime/src/main/resources/application.properties create mode 100644 integration-tests/micrometer-opentelemetry/pom.xml create mode 100644 integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/AppResource.java create mode 100644 integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/ExporterResource.java create mode 100644 integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/SimpleResource.java create mode 100644 integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/CountedBean.java create mode 100644 integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/ManualHistogram.java create mode 100644 integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TestValueResolver.java create mode 100644 integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TraceData.java create mode 100644 integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TracedService.java create mode 100644 integration-tests/micrometer-opentelemetry/src/main/resources/application.properties create mode 100644 integration-tests/micrometer-opentelemetry/src/test/java/io/quarkus/micrometer/opentelemetry/MicrometerCounterInterceptorTest.java diff --git a/.github/native-tests.json b/.github/native-tests.json index cd9b3beb494a9..4d3a6f3815670 100644 --- a/.github/native-tests.json +++ b/.github/native-tests.json @@ -117,7 +117,7 @@ { "category": "Misc4", "timeout": 130, - "test-modules": "picocli-native, gradle, micrometer-mp-metrics, micrometer-prometheus, logging-json, jaxp, jaxb, observability-lgtm, opentelemetry, opentelemetry-jdbc-instrumentation, opentelemetry-mongodb-client-instrumentation, opentelemetry-redis-instrumentation, web-dependency-locator", + "test-modules": "picocli-native, gradle, micrometer-mp-metrics, micrometer-prometheus, logging-json, jaxp, jaxb, observability-lgtm, opentelemetry, opentelemetry-jdbc-instrumentation, opentelemetry-mongodb-client-instrumentation, opentelemetry-redis-instrumentation, web-dependency-locator, micrometer-opentelemetry", "os-name": "ubuntu-latest" }, { diff --git a/bom/application/pom.xml b/bom/application/pom.xml index edc7b96c00e77..29f1af9c12bbf 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -3194,6 +3194,16 @@ quarkus-micrometer ${project.version} + + io.quarkus + quarkus-micrometer-opentelemetry-deployment + ${project.version} + + + io.quarkus + quarkus-micrometer-opentelemetry + ${project.version} + io.quarkus quarkus-micrometer-registry-prometheus-deployment diff --git a/docs/pom.xml b/docs/pom.xml index 8dd5d654a0a7c..2c22f042865be 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -1615,6 +1615,19 @@ + + io.quarkus + quarkus-micrometer-opentelemetry-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-minikube-deployment diff --git a/docs/src/main/asciidoc/telemetry-micrometer-to-opentelemetry.adoc b/docs/src/main/asciidoc/telemetry-micrometer-to-opentelemetry.adoc new file mode 100644 index 0000000000000..38c9dc3c5472a --- /dev/null +++ b/docs/src/main/asciidoc/telemetry-micrometer-to-opentelemetry.adoc @@ -0,0 +1,219 @@ +//// +This guide is maintained in the main Quarkus repository +and pull requests should be submitted there: +https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc +//// +[id=telemetry-micrometer-opentelemetry] += Micrometer and OpenTelemetry extension +include::_attributes.adoc[] +:extension-status: preview +:diataxis-type: reference +:categories: observability +:summary: Guide to send Micrometer data to OpenTelemetry. +:topics: observability,opentelemetry,metrics,micrometer,tracing,logs +:extensions: io.quarkus:quarkus-micrometer-opentelemetry + +This extension provides support for both `Micrometer` and `OpenTelemetry` in Quarkus applications. It streamlines integration by incorporating both extensions along with a bridge that enables sending Micrometer metrics via OpenTelemetry. + +include::{includes}/extension-status.adoc[] + +[NOTE] +==== +- The xref:telemetry-micrometer.adoc[Micrometer Guide] is available for detailed information about the Micrometer extension. +- The xref:opentelemetry.adoc[OpenTelemetry Guide] provides information about the OpenTelemetry extension. +==== + +The bridge is more than the simple OTLP registry found in Quarkiverse. In this extension, the OpenTelemetry SDK provides a Micrometer registry implementation. + +This allows the normal use of the Micrometer API, but have the metrics handled by the OpenTelemetry extension. All the configurations of the OpenTelemetry extension are available for this bridge and enables forwarding to OpenTelemetry all the automatic instrumentation metrics generated by Micrometer in Quarkus, as well as custom user metrics. + +The bridge is based on the https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/micrometer/micrometer-1.5/library[`micrometer/micrometer-1.5`] OpenTelemetry instrumentation library. + +== Usage + +If you already have your Quarkus project configured, you can add the `quarkus-micrometer-opentelemetry` extension to your project by running the following command in your project base directory: + +:add-extension-extensions: micrometer-opentelemetry +include::{includes}/devtools/extension-add.adoc[] + +This will add the following to your build file: + +[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] +.pom.xml +---- + + io.quarkus + quarkus-micrometer-opentelemetry + +---- + +[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] +.build.gradle +---- +implementation("io.quarkus:quarkus-micrometer-opentelemetry") +---- + +== Configuration + +Micrometer is enabled by default as are OpenTelemetry tracing, metrics and logs. +OpenTelemetry metrics auto-instrumentation for HTTP server and JVM metrics are disabled by default because those metrics are already collected by Micrometer. + +Particular automated metrics are not enabled by default and can be enabled by setting the, as an example: +``` +quarkus.micrometer.binder.jvm=true +``` +on the `application.properties` file. + +For this and other properties you can use with the extension, Please refer to: + +* xref:telemetry-micrometer.adoc#configuration-reference[Micrometer metrics configurations] +* xref:opentelemetry.adoc#configuration-reference[OpenTelemetry configurations] + + + +== Metric differences between Micrometer and OpenTelemetry + +=== API differences +The metrics produced with each framework follow different APIs and the mapping is not 1:1. + +One fundamental API difference is that Micrometer uses a https://docs.micrometer.io/micrometer/reference/concepts/timers.html[Timer] and OpenTelemetry uses a https://opentelemetry.io/docs/specs/otel/metrics/data-model/#histogram[Histogram] to record latency (execution time) metrics and the frequency of the events. + +When using the `@Timed` annotation with Micrometer, 2 different metrics are https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/324fdbdd452ddffaf2da2c5bf004d8bb3fdfa1dd/instrumentation/micrometer/micrometer-1.5/library/src/main/java/io/opentelemetry/instrumentation/micrometer/v1_5/OpenTelemetryTimer.java#L31[created on the OpenTelemetry side], one `Gauge` for the `max` value and one `Histogram`. + +The `DistributionSummary` from Micrometer is transformed into a `histogram` and a `DoubleGauge` for the `max` value. If slos are set in the `DistributionSummary` an addition histogram is created for them. + +This table shows the differences between the two frameworks: + +|=== +|Micrometer |OpenTelemetry + +|DistributionSummary +|`` (Histogram), `.max` (DoubleGauge) + +|DistributionSummary with SLOs +|`` (Histogram), `.max` (DoubleGauge), `.histogram` (DoubleGauge) + +|LongTaskTimer +|`.active` (ObservableLongUpDownCounter), `.duration` (ObservableDoubleUpDownCounter) + +|Timer +|`` (Histogram), `.max` (ObservableDoubleGauge) +|=== + + +=== Semantic convention differences + +The 2 frameworks follow different semantic conventions and approaches to the concept. The OpenTelemetry metrics are based on the https://opentelemetry.io/docs/concepts/semantic-conventions/[`OpenTelemetry Semantic Conventions`] and are still under active development. Micrometer metrics convention format is around for a long time and has not changed much. + +When you set these 2 configurations are set in the `application.properties` file: + +``` +quarkus.micrometer.binder.jvm=true +quarkus.micrometer.binder.http-server.enabled=true + +``` +The JVM and HTTP server metrics are collected by Micrometer. + +Next, are examples of the metrics collected by Micrometer and a comparison of what would be the `quarkus-micrometer-registry-prometheus` output vs the one on this bridge. A link to the equivalent OpenTelemetry Semantic Convention is also provided for reference and is not currently used in the bridge. + +|=== +|Micrometer Meter |Quarkus Micrometer Prometheus output | This bridge OpenTelemetry output name | Related OpenTelemetry Semantic Convention (not applied) + +|Using the @Timed interceptor. +| +|method.timed (Histogram), method.timed.max (DoubleGauge) +|NA + +|Using the @Counted interceptor. +| +|method.counted (DoubleSum) +|NA + +|`http.server.active.requests` (Gauge) +|`http_server_active_requests` (Gauge) +|`http.server.active.requests` (DoubleGauge) +|https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserveractive_requests[`http.server.active_requests`] (UpDownCounter) + +|`http.server.requests` (Timer) +|`http_server_requests_seconds_count`, `http_server_requests_seconds_sum`, `http_server_requests_seconds_max` (Gauge) +|`http.server.requests` (Histogram), `http.server.requests.max` (DoubleGauge) +|https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserverrequestduration[`http.server.request.duration`] (Histogram) + +|`http.server.bytes.read` (DistributionSummary) +|`http_server_bytes_read_count`, `http_server_bytes_read_sum` , `http_server_bytes_read_max` (Gauge) +|`http.server.bytes.read` (Histogram), `http.server.bytes.read.max` (DoubleGauge) +|https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserverrequestbodysize[`http.server.request.body.size`] (Histogram) + +|`http.server.bytes.write` (DistributionSummary) +|`http_server_bytes_write_count`, `http_server_bytes_write_sum` , `http_server_bytes_write_max` (Gauge) +|`http.server.bytes.write` (Histogram), `http.server.bytes.write.max` (DoubleGauge) +|https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserverresponsebodysize[`http.server.response.body.size`] (Histogram) + +|`http.server.connections` (LongTaskTimer) +|`http_server_connections_seconds_active_count`, `http_server_connections_seconds_duration_sum` `http_server_connections_seconds_max` (Gauge) +|`http.server.connections.active` (LongSum), `http.server.connections.duration` (DoubleGauge) +| N/A + +|`jvm.threads.live` (Gauge) +|`jvm_threads_live_threads` (Gauge) +|`jvm.threads.live` (DoubleGauge) +|https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#metric-jvmthreadcount[`jvm.threads.live`] (UpDownCounter) + +|`jvm.threads.started` (FunctionCounter) +|`jvm_threads_started_threads_total` (Counter) +|`jvm.threads.started` (DoubleSum) +|https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#metric-jvmthreadcount[`jvm.threads.live`] (UpDownCounter) + +|`jvm.threads.daemon` (Gauge) +|`jvm_threads_daemon_threads` (Gauge) +|`jvm.threads.daemon` (DoubleGauge) +|https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#metric-jvmthreadcount[`jvm.threads.live`] (UpDownCounter) + +|`jvm.threads.peak` (Gauge) +|`jvm_threads_peak_threads` (Gauge) +|`jvm.threads.peak` (DoubleGauge) +|N/A + +|`jvm.threads.states` (Gauge per state) +|`jvm_threads_states_threads` (Gauge) +|`jvm.threads.states` (DoubleGauge) +|https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#metric-jvmthreadcount[`jvm.threads.live`] (UpDownCounter) +|=== + + +[NOTE] +==== +- Some metrics might be absent of the output if they contain no data. +==== + +== See the output + +=== Grafana-OTel-LGTM Dev Service +You can use the xref:observability-devservices-lgtm.adoc[Grafana-OTel-LGTM] devservice. + +This Dev service includes a Grafana for visualizing data, Loki to store logs, Tempo to store traces and Prometheus to store metrics. +Also provides and OTel collector to receive the data. + +=== Logging exporter + +You can output all metrics to the console by setting the exporter to `logging` in the `application.properties` file: +[source, properties] +---- +quarkus.otel.metrics.exporter=logging <1> +quarkus.otel.metric.export.interval=10000ms <2> +---- + +<1> Set the exporter to `logging`. +Normally you don't need to set this. +The default is `cdi`. +<2> Set the interval to export the metrics. +The default is `1m`, which is too long for debugging. + +Also add this dependency to your project: +[source,xml] +---- + + io.opentelemetry + opentelemetry-exporter-logging + +---- diff --git a/extensions/micrometer-opentelemetry/deployment/pom.xml b/extensions/micrometer-opentelemetry/deployment/pom.xml new file mode 100644 index 0000000000000..a500f05fa846a --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/pom.xml @@ -0,0 +1,134 @@ + + + 4.0.0 + + + io.quarkus + quarkus-micrometer-opentelemetry-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-micrometer-opentelemetry-deployment + Quarkus - Micrometer to OpenTelemetry Bridge - Deployment + Micrometer registry implemented by the OpenTelemetry SDK + + + + + io.quarkus + quarkus-micrometer-opentelemetry + ${project.version} + + + + io.quarkus + quarkus-micrometer-deployment + + + + io.quarkus + quarkus-opentelemetry-deployment + + + + + io.quarkus + quarkus-junit5-internal + test + + + + io.quarkus + quarkus-junit5 + test + + + + io.rest-assured + rest-assured + test + + + + org.awaitility + awaitility + test + + + + org.assertj + assertj-core + test + + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + + io.smallrye.reactive + smallrye-mutiny-vertx-web-client + test + + + + io.quarkus + quarkus-rest-client-deployment + test + + + + io.quarkus + quarkus-rest-jackson-deployment + test + + + + io.quarkus + quarkus-vertx-http-deployment + test + + + + io.quarkus + quarkus-reactive-routes-deployment + test + + + + + + + maven-surefire-plugin + + + org.jboss.logmanager.LogManager + INFO + + + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + + \ No newline at end of file diff --git a/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOTelBridgeConfigBuilderCustomizer.java b/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOTelBridgeConfigBuilderCustomizer.java new file mode 100644 index 0000000000000..2db96385db7a6 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOTelBridgeConfigBuilderCustomizer.java @@ -0,0 +1,21 @@ +package io.quarkus.micrometer.opentelemetry.deployment; + +import java.util.Map; + +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfigBuilder; +import io.smallrye.config.SmallRyeConfigBuilderCustomizer; + +public class MicrometerOTelBridgeConfigBuilderCustomizer implements SmallRyeConfigBuilderCustomizer { + @Override + public void configBuilder(final SmallRyeConfigBuilder builder) { + // use a priority of 50 to make sure that this is overridable by any of the standard methods + builder.withSources( + new PropertiesConfigSource(Map.of( + "quarkus.otel.metrics.enabled", "true"), + "quarkus-micrometer-opentelemetry", 1), + new PropertiesConfigSource(Map.of( + "quarkus.otel.logs.enabled", "true"), + "quarkus-micrometer-opentelemetry", 1)); + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java b/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java new file mode 100644 index 0000000000000..b60a6df5cd5a2 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java @@ -0,0 +1,75 @@ +package io.quarkus.micrometer.opentelemetry.deployment; + +import java.util.Locale; +import java.util.function.BooleanSupplier; + +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Singleton; + +import org.jboss.jandex.ClassType; +import org.jboss.jandex.DotName; +import org.jboss.jandex.ParameterizedType; +import org.jboss.jandex.Type; + +import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.api.OpenTelemetry; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +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.RunTimeConfigurationDefaultBuildItem; +import io.quarkus.deployment.builditem.SystemPropertyBuildItem; +import io.quarkus.micrometer.deployment.MicrometerProcessor; +import io.quarkus.micrometer.opentelemetry.runtime.MicrometerOtelBridgeRecorder; +import io.quarkus.opentelemetry.deployment.OpenTelemetryEnabled; +import io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig; +import io.quarkus.opentelemetry.runtime.config.runtime.OTelRuntimeConfig; + +@BuildSteps(onlyIf = { + MicrometerProcessor.MicrometerEnabled.class, + OpenTelemetryEnabled.class, + MicrometerOtelBridgeProcessor.OtlpMetricsExporterEnabled.class }) +public class MicrometerOtelBridgeProcessor { + + @BuildStep + public void disableOTelAutoInstrumentedMetrics(BuildProducer runtimeConfigProducer) { + runtimeConfigProducer.produce( + new RunTimeConfigurationDefaultBuildItem("quarkus.otel.instrument.http-server-metrics", "false")); + runtimeConfigProducer.produce( + new RunTimeConfigurationDefaultBuildItem("quarkus.otel.instrument.jvm-metrics", "false")); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void createBridgeBean(OTelRuntimeConfig otelRuntimeConfig, + MicrometerOtelBridgeRecorder recorder, + BuildProducer syntheticBeanProducer, + BuildProducer systemProperty) { + + syntheticBeanProducer.produce(SyntheticBeanBuildItem.configure(MeterRegistry.class) + .defaultBean() + .setRuntimeInit() + .unremovable() + .scope(Singleton.class) + .addInjectionPoint(ParameterizedType.create(DotName.createSimple(Instance.class), + new Type[] { ClassType.create(DotName.createSimple(OpenTelemetry.class.getName())) }, null)) + .createWith(recorder.createBridge(otelRuntimeConfig)) + .done()); + } + + /** + * No point in activating the bridge if the OTel metrics if off or the exporter is none. + */ + static class OtlpMetricsExporterEnabled implements BooleanSupplier { + OTelBuildConfig otelBuildConfig; + + public boolean getAsBoolean() { + return otelBuildConfig.metrics().enabled().orElse(Boolean.TRUE) && + !otelBuildConfig.metrics().exporter().stream() + .map(exporter -> exporter.toLowerCase(Locale.ROOT)) + .anyMatch(exporter -> exporter.contains("none")); + } + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/main/resources/META-INF/services/io.smallrye.config.SmallRyeConfigBuilderCustomizer b/extensions/micrometer-opentelemetry/deployment/src/main/resources/META-INF/services/io.smallrye.config.SmallRyeConfigBuilderCustomizer new file mode 100644 index 0000000000000..51c9a69b4249d --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/main/resources/META-INF/services/io.smallrye.config.SmallRyeConfigBuilderCustomizer @@ -0,0 +1 @@ +io.quarkus.micrometer.opentelemetry.deployment.MicrometerOTelBridgeConfigBuilderCustomizer \ No newline at end of file diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/DistributionSummaryTest.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/DistributionSummaryTest.java new file mode 100644 index 0000000000000..741a99eafa8fd --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/DistributionSummaryTest.java @@ -0,0 +1,129 @@ +package io.quarkus.micrometer.opentelemetry.deployment; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.attributeEntry; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporter; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporterProvider; +import io.quarkus.test.QuarkusUnitTest; + +public class DistributionSummaryTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addClasses(ManualHistogramBean.class) + .addClasses(InMemoryMetricExporter.class, InMemoryMetricExporterProvider.class) + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider") + .add(new StringAsset(""" + quarkus.otel.metrics.enabled=true\n + quarkus.otel.traces.exporter=none\n + quarkus.otel.logs.exporter=none\n + quarkus.otel.metrics.exporter=in-memory\n + quarkus.otel.metric.export.interval=300ms\n + quarkus.micrometer.binder-enabled-default=false\n + quarkus.micrometer.binder.http-client.enabled=true\n + quarkus.micrometer.binder.http-server.enabled=true\n + quarkus.micrometer.binder.http-server.match-patterns=/one=/two\n + quarkus.micrometer.binder.http-server.ignore-patterns=/two\n + quarkus.micrometer.binder.vertx.enabled=true\n + pingpong/mp-rest/url=${test.url}\n + quarkus.redis.devservices.enabled=false\n + """), + "application.properties")); + + @Inject + ManualHistogramBean manualHistogramBean; + + @Inject + InMemoryMetricExporter exporter; + + @Test + void histogramTest() { + manualHistogramBean.recordHistogram(); + + MetricData testSummary = exporter.getLastFinishedHistogramItem("testSummary", 4); + assertNotNull(testSummary); + assertThat(testSummary) + .hasDescription("This is a test distribution summary") + .hasUnit("things") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + points -> points + .hasSum(555.5) + .hasCount(4) + .hasAttributes(attributeEntry("tag", "value")))); + + MetricData textSummaryMax = exporter.getFinishedMetricItem("testSummary.max"); + assertNotNull(textSummaryMax); + assertThat(textSummaryMax) + .hasDescription("This is a test distribution summary") + .hasDoubleGaugeSatisfying( + gauge -> gauge.hasPointsSatisfying( + point -> point + .hasValue(500) + .hasAttributes(attributeEntry("tag", "value")))); + + MetricData testSummaryHistogram = exporter.getFinishedMetricItem("testSummary.histogram"); // present when SLOs are set + assertNotNull(testSummaryHistogram); + assertThat(testSummaryHistogram) + .hasDoubleGaugeSatisfying( + gauge -> gauge.hasPointsSatisfying( + point -> point + .hasValue(1) + .hasAttributes( + attributeEntry("le", "1"), + attributeEntry("tag", "value")), + point -> point + .hasValue(2) + .hasAttributes( + attributeEntry("le", "10"), + attributeEntry("tag", "value")), + point -> point + .hasValue(3) + .hasAttributes( + attributeEntry("le", "100"), + attributeEntry("tag", "value")), + point -> point + .hasValue(4) + .hasAttributes( + attributeEntry("le", "1000"), + attributeEntry("tag", "value")))); + } + + @ApplicationScoped + public static class ManualHistogramBean { + @Inject + MeterRegistry registry; + + public void recordHistogram() { + DistributionSummary summary = DistributionSummary.builder("testSummary") + .description("This is a test distribution summary") + .baseUnit("things") + .tags("tag", "value") + .serviceLevelObjectives(1, 10, 100, 1000) + .distributionStatisticBufferLength(10) + .register(registry); + + summary.record(0.5); + summary.record(5); + summary.record(50); + summary.record(500); + } + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/MetricsDisabledTest.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/MetricsDisabledTest.java new file mode 100644 index 0000000000000..dc752e9f37384 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/MetricsDisabledTest.java @@ -0,0 +1,77 @@ +package io.quarkus.micrometer.opentelemetry.deployment; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.restassured.RestAssured.when; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporter; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporterProvider; +import io.quarkus.micrometer.opentelemetry.deployment.common.PingPongResource; +import io.quarkus.micrometer.opentelemetry.deployment.common.Util; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class MetricsDisabledTest { + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addClasses(Util.class, + PingPongResource.class) + .addClasses(InMemoryMetricExporter.class, InMemoryMetricExporterProvider.class) + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider") + .add(new StringAsset(""" + quarkus.otel.sdk.disabled=true\n + quarkus.otel.metrics.enabled=true\n + quarkus.otel.traces.exporter=none\n + quarkus.otel.logs.exporter=none\n + quarkus.otel.metrics.exporter=in-memory\n + quarkus.otel.metric.export.interval=300ms\n + quarkus.micrometer.binder.http-client.enabled=true\n + quarkus.micrometer.binder.http-server.enabled=true\n + pingpong/mp-rest/url=${test.url}\n + quarkus.redis.devservices.enabled=false\n + """), + "application.properties")); + + @Inject + protected InMemoryMetricExporter metricExporter; + + protected static String mapToString(Map, ?> map) { + return (String) map.keySet().stream() + .map(key -> "" + key.getKey() + "=" + map.get(key)) + .collect(Collectors.joining(", ", "{", "}")); + } + + @BeforeEach + void setUp() { + metricExporter.reset(); + } + + @Test + void disabledTest() throws InterruptedException { + // The otel metrics are disabled + RestAssured.basePath = "/"; + when().get("/ping/one").then().statusCode(200); + + Thread.sleep(200); + + List metricData = metricExporter.getFinishedMetricItems(); + assertThat(metricData).isEmpty(); + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/CountedResource.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/CountedResource.java new file mode 100644 index 0000000000000..29c772a5a403e --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/CountedResource.java @@ -0,0 +1,64 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import static io.quarkus.micrometer.opentelemetry.deployment.compatibility.MicrometerCounterInterceptorTest.*; +import static java.util.concurrent.CompletableFuture.supplyAsync; + +import java.util.concurrent.CompletableFuture; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.aop.MeterTag; +import io.smallrye.mutiny.Uni; + +@ApplicationScoped +public class CountedResource { + @Counted(value = "metric.none", recordFailuresOnly = true) + public void onlyCountFailures() { + } + + @Counted(value = "metric.all", extraTags = { "extra", "tag" }) + public void countAllInvocations(@MeterTag(key = "do_fail", resolver = TestValueResolver.class) boolean fail) { + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + } + + @Counted(description = "nice description") + public void emptyMetricName(@MeterTag boolean fail) { + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + } + + @Counted(value = "async.none", recordFailuresOnly = true) + public CompletableFuture onlyCountAsyncFailures(GuardedResult guardedResult) { + return supplyAsync(guardedResult::get); + } + + @Counted(value = "async.all", extraTags = { "extra", "tag" }) + public CompletableFuture countAllAsyncInvocations(GuardedResult guardedResult) { + return supplyAsync(guardedResult::get); + } + + @Counted + public CompletableFuture emptyAsyncMetricName(GuardedResult guardedResult) { + return supplyAsync(guardedResult::get); + } + + @Counted(value = "uni.none", recordFailuresOnly = true) + public Uni onlyCountUniFailures(GuardedResult guardedResult) { + return Uni.createFrom().item(guardedResult::get); + } + + @Counted(value = "uni.all", extraTags = { "extra", "tag" }) + public Uni countAllUniInvocations(GuardedResult guardedResult) { + return Uni.createFrom().item(guardedResult::get); + } + + @Counted + public Uni emptyUniMetricName(GuardedResult guardedResult) { + return Uni.createFrom().item(guardedResult::get); + } + +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/GuardedResult.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/GuardedResult.java new file mode 100644 index 0000000000000..642bde50ba8ff --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/GuardedResult.java @@ -0,0 +1,34 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +public class GuardedResult { + + private boolean complete; + private NullPointerException withException; + + public synchronized Object get() { + while (!complete) { + try { + wait(); + } catch (InterruptedException e) { + // Intentionally empty + } + } + + if (withException == null) { + return new Object(); + } + + throw withException; + } + + public synchronized void complete() { + complete(null); + } + + public synchronized void complete(NullPointerException withException) { + this.complete = true; + this.withException = withException; + notifyAll(); + } + +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/HelloResource.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/HelloResource.java new file mode 100644 index 0000000000000..a7d949f2ddac2 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/HelloResource.java @@ -0,0 +1,23 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; + +@Path("/hello") +@Singleton +public class HelloResource { + @GET + @Path("{message}") + public String hello(@PathParam("message") String message) { + return "hello " + message; + } + + @OPTIONS + @Path("{message}") + public String helloOptions(@PathParam("message") String message) { + return "hello " + message; + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporter.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporter.java new file mode 100644 index 0000000000000..d1e1641d12cfb --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporter.java @@ -0,0 +1,181 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Assertions; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.data.PointData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.semconv.SemanticAttributes; +import io.quarkus.arc.Unremovable; + +@Unremovable +@ApplicationScoped +public class InMemoryMetricExporter implements MetricExporter { + + private final Queue finishedMetricItems = new ConcurrentLinkedQueue<>(); + private final AggregationTemporality aggregationTemporality = AggregationTemporality.CUMULATIVE; + private boolean isStopped = false; + + public MetricDataFilter metrics(final String name) { + return new MetricDataFilter(this, name); + } + + public MetricDataFilter get(final String name) { + return new MetricDataFilter(this, name); + } + + public MetricDataFilter find(final String name) { + return new MetricDataFilter(this, name); + } + + /* + * ignore points with /export in the route + */ + private static boolean notExporterPointData(PointData pointData) { + return pointData.getAttributes().asMap().entrySet().stream() + .noneMatch(entry -> entry.getKey().getKey().equals(SemanticAttributes.HTTP_ROUTE.getKey()) && + entry.getValue().toString().contains("/export")); + } + + private static boolean isPathFound(String path, Attributes attributes) { + if (path == null) { + return true;// any match + } + Object value = attributes.asMap().get(AttributeKey.stringKey(SemanticAttributes.HTTP_ROUTE.getKey())); + if (value == null) { + return false; + } + return value.toString().equals(path); + } + + public MetricData getLastFinishedHistogramItem(String testSummary, int count) { + Awaitility.await().atMost(5, SECONDS) + .untilAsserted(() -> Assertions.assertEquals(count, getFinishedMetricItems(testSummary, null).size())); + List metricData = getFinishedMetricItems(testSummary, null); + return metricData.get(metricData.size() - 1);// get last added entry which will be the most recent + } + + public void assertCountDataPointsAtLeast(final String name, final String target, final int count) { + Awaitility.await().atMost(5, SECONDS) + .untilAsserted(() -> Assertions.assertTrue(count < countMaxPoints(name, target))); + } + + public void assertCountDataPointsAtLeastOrEqual(final String name, final String target, final int count) { + Awaitility.await().atMost(5, SECONDS) + .untilAsserted(() -> Assertions.assertTrue(count <= countMaxPoints(name, target))); + } + + public void assertCountDataPointsAtLeastOrEqual(Supplier tag, int count) { + Awaitility.await().atMost(50, SECONDS) + .untilAsserted(() -> Assertions.assertTrue(count <= tag.get().lastReadingPointsSize())); + } + + private Integer countMaxPoints(String name, String target) { + List metricData = getFinishedMetricItems(name, target); + if (metricData.isEmpty()) { + return 0; + } + int size = metricData.get(metricData.size() - 1).getData().getPoints().size(); + return size; + } + + /** + * Returns a {@code List} of the finished {@code Metric}s, represented by {@code MetricData}. + * + * @return a {@code List} of the finished {@code Metric}s. + */ + public List getFinishedMetricItems() { + return Collections.unmodifiableList(new ArrayList<>(finishedMetricItems)); + } + + public MetricData getFinishedMetricItem(String metricName) { + List metricData = getFinishedMetricItems(metricName, null); + if (metricData.isEmpty()) { + return null; + } + return metricData.get(metricData.size() - 1);// get last added entry which will be the most recent + } + + public List getFinishedMetricItems(final String name, final String target) { + return Collections.unmodifiableList(new ArrayList<>( + finishedMetricItems.stream() + .filter(metricData -> metricData.getName().equals(name)) + .filter(metricData -> metricData.getData().getPoints().stream() + .anyMatch(point -> isPathFound(target, point.getAttributes()))) + .collect(Collectors.toList()))); + } + + /** + * Clears the internal {@code List} of finished {@code Metric}s. + * + *

+ * Does not reset the state of this exporter if already shutdown. + */ + public void reset() { + finishedMetricItems.clear(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return aggregationTemporality; + } + + /** + * Exports the collection of {@code Metric}s into the inmemory queue. + * + *

+ * If this is called after {@code shutdown}, this will return {@code ResultCode.FAILURE}. + */ + @Override + public CompletableResultCode export(Collection metrics) { + if (isStopped) { + return CompletableResultCode.ofFailure(); + } + finishedMetricItems.addAll(metrics); + return CompletableResultCode.ofSuccess(); + } + + /** + * The InMemory exporter does not batch metrics, so this method will immediately return with + * success. + * + * @return always Success + */ + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + /** + * Clears the internal {@code List} of finished {@code Metric}s. + * + *

+ * Any subsequent call to export() function on this MetricExporter, will return {@code + * CompletableResultCode.ofFailure()} + */ + @Override + public CompletableResultCode shutdown() { + isStopped = true; + finishedMetricItems.clear(); + return CompletableResultCode.ofSuccess(); + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporterProvider.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporterProvider.java new file mode 100644 index 0000000000000..44aba77c9976b --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporterProvider.java @@ -0,0 +1,19 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import jakarta.enterprise.inject.spi.CDI; + +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider; +import io.opentelemetry.sdk.metrics.export.MetricExporter; + +public class InMemoryMetricExporterProvider implements ConfigurableMetricExporterProvider { + @Override + public MetricExporter createExporter(ConfigProperties configProperties) { + return CDI.current().select(InMemoryMetricExporter.class).get(); + } + + @Override + public String getName() { + return "in-memory"; + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/MetricDataFilter.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/MetricDataFilter.java new file mode 100644 index 0000000000000..f7db4076f8739 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/MetricDataFilter.java @@ -0,0 +1,247 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import static io.opentelemetry.semconv.HttpAttributes.HTTP_ROUTE; +import static io.opentelemetry.semconv.UrlAttributes.URL_PATH; + +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.metrics.data.Data; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.data.MetricDataType; +import io.opentelemetry.sdk.metrics.data.PointData; +import io.opentelemetry.sdk.resources.Resource; + +public class MetricDataFilter { + private Stream metricData; + + public MetricDataFilter(final InMemoryMetricExporter metricExporter, final String name) { + metricData = metricExporter.getFinishedMetricItems() + .stream() + .filter(metricData -> metricData.getName().equals(name)); + } + + public MetricDataFilter route(final String route) { + metricData = metricData.map(new Function() { + @Override + public MetricData apply(final MetricData metricData) { + return new MetricData() { + @Override + public Resource getResource() { + return metricData.getResource(); + } + + @Override + public InstrumentationScopeInfo getInstrumentationScopeInfo() { + return metricData.getInstrumentationScopeInfo(); + } + + @Override + public String getName() { + return metricData.getName(); + } + + @Override + public String getDescription() { + return metricData.getDescription(); + } + + @Override + public String getUnit() { + return metricData.getUnit(); + } + + @Override + public MetricDataType getType() { + return metricData.getType(); + } + + @Override + public Data getData() { + return new Data() { + @Override + public Collection getPoints() { + return metricData.getData().getPoints().stream().filter(new Predicate() { + @Override + public boolean test(final PointData pointData) { + String value = pointData.getAttributes().get(HTTP_ROUTE); + return value != null && value.equals(route); + } + }).collect(Collectors.toSet()); + } + }; + } + }; + } + }); + return this; + } + + public MetricDataFilter path(final String path) { + metricData = metricData.map(new Function() { + @Override + public MetricData apply(final MetricData metricData) { + return new MetricData() { + @Override + public Resource getResource() { + return metricData.getResource(); + } + + @Override + public InstrumentationScopeInfo getInstrumentationScopeInfo() { + return metricData.getInstrumentationScopeInfo(); + } + + @Override + public String getName() { + return metricData.getName(); + } + + @Override + public String getDescription() { + return metricData.getDescription(); + } + + @Override + public String getUnit() { + return metricData.getUnit(); + } + + @Override + public MetricDataType getType() { + return metricData.getType(); + } + + @Override + public Data getData() { + return new Data() { + @Override + public Collection getPoints() { + return metricData.getData().getPoints().stream().filter(new Predicate() { + @Override + public boolean test(final PointData pointData) { + String value = pointData.getAttributes().get(URL_PATH); + return value != null && value.equals(path); + } + }).collect(Collectors.toSet()); + } + }; + } + }; + } + }); + return this; + } + + public MetricDataFilter tag(final String key, final String value) { + return stringAttribute(key, value); + } + + public MetricDataFilter stringAttribute(final String key, final String value) { + metricData = metricData.map(new Function() { + @Override + public MetricData apply(final MetricData metricData) { + return new MetricData() { + @Override + public Resource getResource() { + return metricData.getResource(); + } + + @Override + public InstrumentationScopeInfo getInstrumentationScopeInfo() { + return metricData.getInstrumentationScopeInfo(); + } + + @Override + public String getName() { + return metricData.getName(); + } + + @Override + public String getDescription() { + return metricData.getDescription(); + } + + @Override + public String getUnit() { + return metricData.getUnit(); + } + + @Override + public MetricDataType getType() { + return metricData.getType(); + } + + @Override + public Data getData() { + return new Data() { + @Override + public Collection getPoints() { + return metricData.getData().getPoints().stream().filter(new Predicate() { + @Override + public boolean test(final PointData pointData) { + String v = pointData.getAttributes().get(AttributeKey.stringKey(key)); + boolean result = v != null && v.equals(value); + if (!result) { + System.out.println( + "\nNot Matching. Expected: " + key + " = " + value + " -> Found: " + v); + } + return result; + } + }).collect(Collectors.toSet()); + } + }; + } + }; + } + }); + return this; + } + + public List getAll() { + return metricData.collect(Collectors.toList()); + } + + public MetricData lastReading() { + return metricData.reduce((first, second) -> second) + .orElseThrow(() -> new IllegalArgumentException("Stream has no elements")); + } + + public int lastReadingPointsSize() { + return metricData.reduce((first, second) -> second) + .map(data -> data.getData().getPoints().size()) + .orElseThrow(() -> new IllegalArgumentException("Stream has no elements")); + } + + /** + * Returns the first point data of the last reading. + * Assumes only one data point can be present. + * + * @param pointDataClass + * @param + * @return + */ + public T lastReadingDataPoint(Class pointDataClass) { + List list = lastReading().getData().getPoints().stream() + .map(pointData -> (T) pointData) + .toList(); + + if (list.size() == 0) { + throw new IllegalArgumentException("Stream has no elements"); + } + if (list.size() > 1) { + throw new IllegalArgumentException("Stream has more than one element"); + } + return list.get(0); + } + + public int countPoints(final MetricData metricData) { + return metricData.getData().getPoints().size(); + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/PingPongResource.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/PingPongResource.java new file mode 100644 index 0000000000000..6b2c5faba1d17 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/PingPongResource.java @@ -0,0 +1,75 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import java.util.concurrent.CompletionStage; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +@Path("/") +@Singleton +public class PingPongResource { + @RegisterRestClient(configKey = "pingpong") + public interface PingPongRestClient { + @GET + @Path("pong/{message}") + String pingpong(@PathParam("message") String message); + + @GET + @Path("pong/{message}") + CompletionStage asyncPingPong(@PathParam("message") String message); + } + + @Inject + @RestClient + PingPongRestClient pingRestClient; + + @GET + @Path("pong/{message}") + public Response pong(@PathParam("message") String message) { + if (message.equals("500")) { + return Response.status(500).build(); + } else if (message.equals("400")) { + return Response.status(400).build(); + } + return Response.ok(message, "text/plain").build(); + } + + @GET + @Path("ping/{message}") + public String ping(@PathParam("message") String message) { + try { + return pingRestClient.pingpong(message); + } catch (Exception ex) { + if (!"400".equals(message) && !"500".equals(message)) { + throw ex; + } + // expected exception + } + return message; + } + + @GET + @Path("async-ping/{message}") + public CompletionStage asyncPing(@PathParam("message") String message) { + return pingRestClient.asyncPingPong(message); + } + + @GET + @Path("one") + public String one() { + return "OK"; + } + + @GET + @Path("two") + public String two() { + return "OK"; + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/ServletEndpoint.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/ServletEndpoint.java new file mode 100644 index 0000000000000..9a685085dd991 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/ServletEndpoint.java @@ -0,0 +1,18 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet(name = "ServletEndpoint", urlPatterns = "/servlet/*") +public class ServletEndpoint extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("text/plain"); + resp.getWriter().println("OK"); + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/TimedResource.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/TimedResource.java new file mode 100644 index 0000000000000..63b7bad1f375e --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/TimedResource.java @@ -0,0 +1,66 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import static java.util.concurrent.CompletableFuture.supplyAsync; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.CompletableFuture; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.micrometer.core.annotation.Timed; +import io.smallrye.mutiny.Uni; + +@ApplicationScoped +public class TimedResource { + @Timed(value = "call", extraTags = { "extra", "tag" }) + public void call(boolean fail) { + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + + } + + @Timed(value = "longCall", extraTags = { "extra", "tag" }, longTask = true) + public void longCall(boolean fail) { + try { + Thread.sleep(3); + } catch (InterruptedException e) { + } + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + } + + @Timed(value = "async.call", extraTags = { "extra", "tag" }) + public CompletableFuture asyncCall(GuardedResult guardedResult) { + return supplyAsync(guardedResult::get); + } + + @Timed(value = "uni.call", extraTags = { "extra", "tag" }) + public Uni uniCall(GuardedResult guardedResult) { + return Uni.createFrom().item(guardedResult::get); + } + + @Timed(value = "async.longCall", extraTags = { "extra", "tag" }, longTask = true) + public CompletableFuture longAsyncCall(GuardedResult guardedResult) { + try { + Thread.sleep(3); + } catch (InterruptedException e) { + } + return supplyAsync(guardedResult::get); + } + + @Timed(value = "uni.longCall", extraTags = { "extra", "tag" }, longTask = true) + public Uni longUniCall(GuardedResult guardedResult) { + return Uni.createFrom().item(guardedResult::get).onItem().delayIt().by(Duration.of(3, ChronoUnit.MILLIS)); + } + + @Timed(value = "alpha", extraTags = { "extra", "tag" }) + @Timed(value = "bravo", extraTags = { "extra", "tag" }) + public void repeatableCall(boolean fail) { + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/Util.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/Util.java new file mode 100644 index 0000000000000..41e193deb05af --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/Util.java @@ -0,0 +1,75 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.logging.LogRecord; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Assertions; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; + +public class Util { + private Util() { + } + + static void assertMessage(String attribute, List records) { + // look through log records and make sure there is a message about the specific attribute + long i = records.stream().filter(x -> Arrays.stream(x.getParameters()).anyMatch(y -> y.equals(attribute))) + .count(); + Assertions.assertEquals(1, i); + } + + static String stackToString(Throwable t) { + StringBuilder sb = new StringBuilder().append("\n"); + while (t.getCause() != null) { + t = t.getCause(); + } + sb.append(t.getClass()).append(": ").append(t.getMessage()).append("\n"); + Arrays.asList(t.getStackTrace()).forEach(x -> sb.append("\t").append(x.toString()).append("\n")); + return sb.toString(); + } + + public static String foundServerRequests(MeterRegistry registry, String message) { + return message + "\nFound:\n" + Util.listMeters(registry, "http.server.requests"); + } + + public static String foundClientRequests(MeterRegistry registry, String message) { + return message + "\nFound:\n" + Util.listMeters(registry, "http.client.requests"); + } + + public static String listMeters(MeterRegistry registry, String meterName) { + return registry.find(meterName).meters().stream() + .map(x -> { + return x.getId().toString(); + }) + .collect(Collectors.joining("\n")); + } + + public static String listMeters(MeterRegistry registry, String meterName, final String tag) { + return registry.find(meterName).meters().stream() + .map(x -> { + return x.getId().getTag(tag); + }) + .collect(Collectors.joining(",")); + } + + public static void waitForMeters(Collection collection, int count) throws InterruptedException { + int i = 0; + do { + Thread.sleep(3); + } while (collection.size() < count && i++ < 10); + } + + public static void assertTags(Tag tag, Meter... meters) { + for (Meter meter : meters) { + assertThat(meter.getId().getTags().contains(tag)); + } + } + +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/VertxWebEndpoint.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/VertxWebEndpoint.java new file mode 100644 index 0000000000000..3e573f2189470 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/VertxWebEndpoint.java @@ -0,0 +1,24 @@ +package io.quarkus.micrometer.opentelemetry.deployment.common; + +import io.quarkus.vertx.web.Param; +import io.quarkus.vertx.web.Route; +import io.quarkus.vertx.web.Route.HttpMethod; +import io.quarkus.vertx.web.RouteBase; + +@RouteBase(path = "/vertx") +public class VertxWebEndpoint { + @Route(path = "item/:id", methods = HttpMethod.GET) + public String item(@Param("id") Integer id) { + return "message with id " + id; + } + + @Route(path = "item/:id/:sub", methods = HttpMethod.GET) + public String item(@Param("id") Integer id, @Param("sub") Integer sub) { + return "message with id " + id + " and sub " + sub; + } + + @Route(path = "echo/:msg", methods = { HttpMethod.HEAD, HttpMethod.GET, HttpMethod.OPTIONS }) + public String echo(@Param("msg") String msg) { + return "echo " + msg; + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/HttpCompatibilityTest.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/HttpCompatibilityTest.java new file mode 100644 index 0000000000000..e1984cce5dffb --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/HttpCompatibilityTest.java @@ -0,0 +1,235 @@ +package io.quarkus.micrometer.opentelemetry.deployment.compatibility; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.restassured.RestAssured.when; + +import java.util.Comparator; +import java.util.List; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.quarkus.micrometer.opentelemetry.deployment.common.HelloResource; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporter; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporterProvider; +import io.quarkus.micrometer.opentelemetry.deployment.common.PingPongResource; +import io.quarkus.micrometer.opentelemetry.deployment.common.ServletEndpoint; +import io.quarkus.micrometer.opentelemetry.deployment.common.Util; +import io.quarkus.micrometer.opentelemetry.deployment.common.VertxWebEndpoint; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +/** + * Copy of io.quarkus.micrometer.deployment.binder.UriTagTest + */ +public class HttpCompatibilityTest { + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addClasses(Util.class, + PingPongResource.class, + PingPongResource.PingPongRestClient.class, + ServletEndpoint.class, + VertxWebEndpoint.class, + HelloResource.class) + .addClasses(InMemoryMetricExporter.class, InMemoryMetricExporterProvider.class) + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider") + .add(new StringAsset(""" + quarkus.otel.metrics.exporter=in-memory\n + quarkus.otel.metric.export.interval=300ms\n + quarkus.micrometer.binder-enabled-default=false\n + quarkus.micrometer.binder.http-client.enabled=true\n + quarkus.micrometer.binder.http-server.enabled=true\n + quarkus.micrometer.binder.http-server.match-patterns=/one=/two\n + quarkus.micrometer.binder.http-server.ignore-patterns=/two\n + quarkus.micrometer.binder.vertx.enabled=true\n + pingpong/mp-rest/url=${test.url}\n + quarkus.redis.devservices.enabled=false\n + """), + "application.properties")); + public static final AttributeKey URI = AttributeKey.stringKey("uri"); + public static final AttributeKey METHOD = AttributeKey.stringKey("method"); + public static final AttributeKey STATUS = AttributeKey.stringKey("status"); + + @Inject + protected InMemoryMetricExporter metricExporter; + + @BeforeEach + void setUp() { + metricExporter.reset(); + } + + /** + * Same as io.quarkus.micrometer.deployment.binder.UriTagTest. + * Makes sure we are getting equivalent results in OTel. + * Micrometer uses timers and OTel uses histograms. + */ + @Test + void testHttpTimerToHistogramCompatibility() { + RestAssured.basePath = "/"; + + // Server paths (templated) + when().get("/one").then().statusCode(200); + when().get("/two").then().statusCode(200); + when().get("/vertx/item/123").then().statusCode(200); + when().get("/vertx/item/1/123").then().statusCode(200); + // when().get("/servlet/12345").then().statusCode(200); + + // Server GET vs. HEAD methods -- templated + when().get("/hello/one").then().statusCode(200); + when().get("/hello/two").then().statusCode(200); + when().head("/hello/three").then().statusCode(200); + when().head("/hello/four").then().statusCode(200); + when().get("/vertx/echo/thing1").then().statusCode(200); + when().get("/vertx/echo/thing2").then().statusCode(200); + when().head("/vertx/echo/thing3").then().statusCode(200); + when().head("/vertx/echo/thing4").then().statusCode(200); + + // Server -> Rest client -> Server (templated) + when().get("/ping/one").then().statusCode(200); + when().get("/ping/two").then().statusCode(200); + when().get("/ping/three").then().statusCode(200); + when().get("/ping/400").then().statusCode(200); + when().get("/ping/500").then().statusCode(200); + when().get("/async-ping/one").then().statusCode(200); + when().get("/async-ping/two").then().statusCode(200); + when().get("/async-ping/three").then().statusCode(200); + + metricExporter.assertCountDataPointsAtLeast("http.server.requests", null, 10); + final List metricDataList = metricExporter.getFinishedMetricItems("http.server.requests", null); + final MetricData metricData = metricDataList.stream() + .max(Comparator.comparingInt(data -> data.getData().getPoints().size())) + .get(); + + metricDataList.forEach(System.out::println); + + assertThat(metricData.getInstrumentationScopeInfo().getName()) + .isEqualTo("io.opentelemetry.micrometer-1.5"); + + // /one should map to /two, which is ignored. + // Neither should exist w/ timers because they were disabled in the configuration. + assertThat(metricData.getHistogramData().getPoints().stream() + .anyMatch(point -> point.getAttributes().get(URI).equals("/one") || + point.getAttributes().get(URI).equals("/two"))) + .isFalse(); + + assertThat(metricData) + .hasName("http.server.requests") // in OTel it should be "http.server.request.duration" + .hasDescription("HTTP server request processing time") // in OTel it should be "Duration of HTTP server requests." + .hasUnit("ms") // OTel has seconds + .hasHistogramSatisfying(histogram -> histogram.isCumulative() + .hasPointsSatisfying( + // valid entries + point -> point.hasCount(1) + .hasAttributesSatisfying( + // FIXME "uri" not following conventions and should be "http.route" + equalTo(URI, "/vertx/item/{id}"), + // FIXME method not following conventions and should be "http.request.method" + equalTo(METHOD, "GET"), + // FIXME status_code not following conventions and should be + // "http.response.status_code" and it should use a long key and not a string key + equalTo(STATUS, "200")), + point -> point.hasCount(1) + .hasAttributesSatisfying( + equalTo(URI, "/vertx/item/{id}/{sub}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "200")), + point -> point.hasCount(2) + .hasAttributesSatisfying( + equalTo(URI, "/hello/{message}"), + equalTo(METHOD, "HEAD"), + equalTo(STATUS, "200")), + point -> point.hasCount(2) + .hasAttributesSatisfying( + equalTo(URI, "/hello/{message}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "200")), + point -> point.hasCount(2) + .hasAttributesSatisfying( + equalTo(URI, "/vertx/echo/{msg}"), + equalTo(METHOD, "HEAD"), + equalTo(STATUS, "200")), + point -> point.hasCount(2) + .hasAttributesSatisfying( + equalTo(URI, "/vertx/echo/{msg}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "200")), + point -> point.hasCount(5) // 3 x 200 + 400 + 500 status codes + .hasAttributesSatisfying( + equalTo(URI, "/ping/{message}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "200")), + point -> point.hasCount(3) + .hasAttributesSatisfying( + equalTo(URI, "/async-ping/{message}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "200")), + point -> point.hasCount(6) // 3 sync requests and 3 async requests + .hasAttributesSatisfying( + equalTo(URI, "/pong/{message}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "200")), + point -> point.hasCount(1) + .hasAttributesSatisfying( + equalTo(URI, "/pong/{message}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "500")), + point -> point.hasCount(1) + .hasAttributesSatisfying( + equalTo(URI, "/pong/{message}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "400")))); + + // OTel metrics are not enabled + assertThat(metricExporter.getFinishedMetricItem("http.server.request.duration")).isNull(); + + metricExporter.assertCountDataPointsAtLeast("http.client.requests", null, 2); + final List clientMetricDataList = metricExporter.getFinishedMetricItems("http.client.requests", null); + final MetricData clientMetricData = clientMetricDataList.stream() + .max(Comparator.comparingInt(data -> data.getData().getPoints().size())) + .get(); + + clientMetricDataList.forEach(System.out::println); + + assertThat(clientMetricData.getInstrumentationScopeInfo().getName()) + .isEqualTo("io.opentelemetry.micrometer-1.5"); + + assertThat(clientMetricData) + .hasName("http.client.requests") // in OTel it should be "http.server.request.duration" + .hasDescription("") // in OTel it should be "Duration of HTTP client requests." + .hasUnit("ms") // OTel has seconds + .hasHistogramSatisfying(histogram -> histogram.isCumulative() + .hasPointsSatisfying( + // valid entries + point -> point.hasCount(1) + .hasAttributesSatisfying( + // FIXME "uri" not following conventions and should be "http.route" + equalTo(URI, "/pong/{message}"), + // FIXME method not following conventions and should be "http.request.method" + equalTo(METHOD, "GET"), + // FIXME status_code not following conventions and should be + // "http.response.status_code" and it should use a long key and not a string key + equalTo(STATUS, "400")), + point -> point.hasCount(1) + .hasAttributesSatisfying( + equalTo(URI, "/pong/{message}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "500")), + point -> point.hasCount(6) // 3 sync requests and 3 async requests + .hasAttributesSatisfying( + equalTo(URI, "/pong/{message}"), + equalTo(METHOD, "GET"), + equalTo(STATUS, "200")))); + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/JvmCompatibilityTest.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/JvmCompatibilityTest.java new file mode 100644 index 0000000000000..0053349a2de79 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/JvmCompatibilityTest.java @@ -0,0 +1,105 @@ +package io.quarkus.micrometer.opentelemetry.deployment.compatibility; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import java.util.Comparator; +import java.util.List; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporter; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporterProvider; +import io.quarkus.micrometer.opentelemetry.deployment.common.PingPongResource; +import io.quarkus.micrometer.opentelemetry.deployment.common.Util; +import io.quarkus.test.QuarkusUnitTest; + +public class JvmCompatibilityTest { + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addClasses(Util.class, + PingPongResource.class, + PingPongResource.PingPongRestClient.class) + .addClasses(InMemoryMetricExporter.class, InMemoryMetricExporterProvider.class) + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider") + .add(new StringAsset(""" + quarkus.otel.metrics.exporter=in-memory\n + quarkus.otel.metric.export.interval=300ms\n + quarkus.micrometer.binder-enabled-default=false\n + quarkus.micrometer.binder.jvm=true\n + quarkus.redis.devservices.enabled=false\n + """), + "application.properties")); + + @Inject + protected InMemoryMetricExporter metricExporter; + + // No need to reset tests for JVM + + @Test + void testDoubleSum() { + metricExporter.assertCountDataPointsAtLeastOrEqual("jvm.threads.started", null, 1); + final List metricDataList = metricExporter.getFinishedMetricItems("jvm.threads.started", null); + + metricDataList.forEach(System.out::println); + + final MetricData metricData = metricDataList.stream() + .max(Comparator.comparingInt(data -> data.getData().getPoints().size())) + .get(); + + assertThat(metricData.getInstrumentationScopeInfo().getName()) + .isEqualTo("io.opentelemetry.micrometer-1.5"); + + assertThat(metricData) + .hasName("jvm.threads.started") + .hasDescription("The total number of application threads started in the JVM") + .hasUnit("threads") + .hasDoubleSumSatisfying(doubleSumAssert -> doubleSumAssert + .isMonotonic() + .isCumulative() + .hasPointsSatisfying(point -> point + .satisfies(actual -> assertThat(actual.getValue()).isGreaterThanOrEqualTo(1.0)) + .hasAttributesSatisfying(attributes -> attributes.isEmpty()))); + } + + @Test + void testDoubleGauge() { + metricExporter.assertCountDataPointsAtLeastOrEqual("jvm.classes.loaded", null, 1); + final List metricDataList = metricExporter.getFinishedMetricItems("jvm.classes.loaded", null); + + metricDataList.forEach(System.out::println); + + final MetricData metricData = metricDataList.stream() + .max(Comparator.comparingInt(data -> data.getData().getPoints().size())) + .get(); + + assertThat(metricData.getInstrumentationScopeInfo().getName()) + .isEqualTo("io.opentelemetry.micrometer-1.5"); + + assertThat(metricData) + .hasName("jvm.classes.loaded") + .hasDescription("The number of classes that are currently loaded in the Java virtual machine") + .hasUnit("classes") + .hasDoubleGaugeSatisfying(doubleSumAssert -> doubleSumAssert + .hasPointsSatisfying(point -> point + .satisfies(actual -> assertThat(actual.getValue()).isGreaterThanOrEqualTo(1.0)) + .hasAttributesSatisfying(attributes -> attributes.isEmpty()))); + } + + // @Test + // void printAll() { + // metricExporter.assertCountDataPointsAtLeastOrEqual("jvm.threads.started", null, 1); + // final List metricDataList = metricExporter.getFinishedMetricItems(); + // + // metricDataList.forEach(System.out::println); + // } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerCounterInterceptorTest.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerCounterInterceptorTest.java new file mode 100644 index 0000000000000..5e7c557c7d99f --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerCounterInterceptorTest.java @@ -0,0 +1,125 @@ +package io.quarkus.micrometer.opentelemetry.deployment.compatibility; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.attributeEntry; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.micrometer.common.annotation.ValueResolver; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.aop.MeterTag; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporter; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporterProvider; +import io.quarkus.micrometer.opentelemetry.deployment.common.Util; +import io.quarkus.test.QuarkusUnitTest; + +/** + * Copy of io.quarkus.micrometer.runtime.MicrometerCounterInterceptorTest + */ +public class MicrometerCounterInterceptorTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addClasses(Util.class, CountedBean.class, TestValueResolver.class) + .addClasses(InMemoryMetricExporter.class, InMemoryMetricExporterProvider.class) + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider") + .add(new StringAsset(""" + quarkus.otel.metrics.exporter=in-memory\n + quarkus.otel.metric.export.interval=300ms\n + quarkus.micrometer.binder.http-client.enabled=true\n + quarkus.micrometer.binder.http-server.enabled=true\n + quarkus.redis.devservices.enabled=false\n + """), + "application.properties")); + + @Inject + CountedBean countedBean; + + @Inject + InMemoryMetricExporter exporter; + + @BeforeEach + void setup() { + exporter.reset(); + } + + @Test + void testCountAllMetrics() { + countedBean.countAllInvocations(false); + Assertions.assertThrows(NullPointerException.class, () -> countedBean.countAllInvocations(true)); + + exporter.assertCountDataPointsAtLeastOrEqual("metric.all", null, 2); + + MetricData metricAll = exporter.getFinishedMetricItem("metric.all"); + assertThat(metricAll) + .isNotNull() + .hasName("metric.all") + .hasDescription("")// currently empty + .hasUnit("")// currently empty + .hasDoubleSumSatisfying(sum -> sum.hasPointsSatisfying( + point -> point + .hasValue(1d) + .hasAttributes(attributeEntry( + "class", + "io.quarkus.micrometer.opentelemetry.deployment.compatibility.MicrometerCounterInterceptorTest$CountedBean"), + attributeEntry("method", "countAllInvocations"), + attributeEntry("extra", "tag"), + attributeEntry("do_fail", "prefix_false"), + attributeEntry("exception", "none"), + attributeEntry("result", "success")), + point -> point + .hasValue(1d) + .hasAttributes(attributeEntry( + "class", + "io.quarkus.micrometer.opentelemetry.deployment.compatibility.MicrometerCounterInterceptorTest$CountedBean"), + attributeEntry("method", "countAllInvocations"), + attributeEntry("extra", "tag"), + attributeEntry("do_fail", "prefix_true"), + attributeEntry("exception", "NullPointerException"), + attributeEntry("result", "failure")))); + } + + @ApplicationScoped + public static class CountedBean { + @Counted(value = "metric.none", recordFailuresOnly = true) + public void onlyCountFailures() { + } + + @Counted(value = "metric.all", extraTags = { "extra", "tag" }) + public void countAllInvocations(@MeterTag(key = "do_fail", resolver = TestValueResolver.class) boolean fail) { + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + } + + @Counted(description = "nice description") + public void emptyMetricName(@MeterTag boolean fail) { + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + } + } + + @Singleton + public static class TestValueResolver implements ValueResolver { + @Override + public String resolve(Object parameter) { + return "prefix_" + parameter; + } + } + +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerTimedInterceptorTest.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerTimedInterceptorTest.java new file mode 100644 index 0000000000000..69adfcec83709 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerTimedInterceptorTest.java @@ -0,0 +1,315 @@ +package io.quarkus.micrometer.opentelemetry.deployment.compatibility; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.opentelemetry.sdk.metrics.data.DoublePointData; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.metrics.data.LongPointData; +import io.quarkus.micrometer.opentelemetry.deployment.common.CountedResource; +import io.quarkus.micrometer.opentelemetry.deployment.common.GuardedResult; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporter; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporterProvider; +import io.quarkus.micrometer.opentelemetry.deployment.common.MetricDataFilter; +import io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +/** + * Copy of io.quarkus.micrometer.runtime.MicrometerTimedInterceptorTest + */ +public class MicrometerTimedInterceptorTest { + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withConfigurationResource("test-logging.properties") + .overrideConfigKey("quarkus.otel.metrics.exporter", "in-memory") + .overrideConfigKey("quarkus.otel.metric.export.interval", "300ms") + .overrideConfigKey("quarkus.micrometer.binder.mp-metrics.enabled", "false") + .overrideConfigKey("quarkus.micrometer.binder.vertx.enabled", "false") + .overrideConfigKey("quarkus.micrometer.registry-enabled-default", "false") + .withApplicationRoot((jar) -> jar + .addClass(CountedResource.class) + .addClass(TimedResource.class) + .addClass(GuardedResult.class) + .addClasses(InMemoryMetricExporter.class, InMemoryMetricExporterProvider.class, MetricDataFilter.class) + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider")); + + @Inject + TimedResource timed; + + @Inject + InMemoryMetricExporter metricExporter; + + @Test + void testTimeMethod() { + timed.call(false); + + metricExporter.assertCountDataPointsAtLeastOrEqual("call", null, 1); + assertEquals(1, metricExporter.get("call") + .tag("method", "call") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "none") + .tag("extra", "tag") + .lastReadingDataPoint(HistogramPointData.class).getCount()); + + assertThat(metricExporter.get("call.max") + .tag("method", "call") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "none") + .tag("extra", "tag") + .lastReadingDataPoint(DoublePointData.class).getValue()) + .isGreaterThan(0); + } + + @Test + void testTimeMethod_Failed() { + assertThrows(NullPointerException.class, () -> timed.call(true)); + + Supplier metricFilterSupplier = () -> metricExporter.get("call") + .tag("method", "call") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "NullPointerException") + .tag("extra", "tag"); + + metricExporter.assertCountDataPointsAtLeastOrEqual(metricFilterSupplier, 1); + assertEquals(1, metricFilterSupplier.get() + .lastReadingDataPoint(HistogramPointData.class).getCount()); + + assertThat(metricExporter.get("call.max") + .tag("method", "call") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "NullPointerException") + .tag("extra", "tag") + .lastReadingDataPoint(DoublePointData.class).getValue()) + .isGreaterThan(0); + } + + @Test + void testTimeMethod_Async() { + GuardedResult guardedResult = new GuardedResult(); + CompletableFuture completableFuture = timed.asyncCall(guardedResult); + guardedResult.complete(); + completableFuture.join(); + + Supplier metricFilterSupplier = () -> metricExporter.get("async.call") + .tag("method", "asyncCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "none") + .tag("extra", "tag"); + + metricExporter.assertCountDataPointsAtLeastOrEqual(metricFilterSupplier, 1); + assertEquals(1, metricFilterSupplier.get() + .lastReadingDataPoint(HistogramPointData.class).getCount()); + + assertThat(metricExporter.get("async.call.max") + .tag("method", "asyncCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "none") + .tag("extra", "tag") + .lastReadingDataPoint(DoublePointData.class).getValue()) + .isGreaterThan(0); + } + + @Test + void testTimeMethod_AsyncFailed() { + GuardedResult guardedResult = new GuardedResult(); + CompletableFuture completableFuture = timed.asyncCall(guardedResult); + guardedResult.complete(new NullPointerException()); + assertThrows(java.util.concurrent.CompletionException.class, () -> completableFuture.join()); + + metricExporter.assertCountDataPointsAtLeastOrEqual("async.call", null, 1); + assertEquals(1, metricExporter.get("async.call") + .tag("method", "asyncCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "NullPointerException") + .tag("extra", "tag") + .lastReadingDataPoint(HistogramPointData.class).getCount()); + + assertThat(metricExporter.get("async.call.max") + .tag("method", "asyncCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "NullPointerException") + .tag("extra", "tag") + .lastReadingDataPoint(DoublePointData.class).getValue()) + .isGreaterThan(0); + } + + @Test + void testTimeMethod_Uni() { + GuardedResult guardedResult = new GuardedResult(); + Uni uni = timed.uniCall(guardedResult); + guardedResult.complete(); + uni.subscribe().asCompletionStage().join(); + + metricExporter.assertCountDataPointsAtLeastOrEqual("uni.call", null, 1); + assertEquals(1, metricExporter.get("uni.call") + .tag("method", "uniCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "none") + .tag("extra", "tag") + .lastReadingDataPoint(HistogramPointData.class).getCount()); + + assertThat(metricExporter.get("uni.call.max") + .tag("method", "uniCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "none") + .tag("extra", "tag") + .lastReadingDataPoint(DoublePointData.class).getValue()) + .isGreaterThan(0); + } + + @Test + void testTimeMethod_UniFailed() throws InterruptedException { + GuardedResult guardedResult = new GuardedResult(); + Uni uni = timed.uniCall(guardedResult); + guardedResult.complete(new NullPointerException()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> uni.subscribe().asCompletionStage().join()); + + // this needs to be executed inline, otherwise the results will be old. + Supplier metricFilterSupplier = () -> metricExporter.get("uni.call") + .tag("method", "uniCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "NullPointerException") + .tag("extra", "tag"); + + metricExporter.assertCountDataPointsAtLeastOrEqual(metricFilterSupplier, 1); + assertEquals(1, metricFilterSupplier.get() + .lastReadingDataPoint(HistogramPointData.class).getCount()); + + assertThat(metricExporter.get("uni.call.max") + .tag("method", "uniCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "NullPointerException") + .tag("extra", "tag") + .lastReadingDataPoint(DoublePointData.class).getValue()) + .isGreaterThan(0); + } + + @Test + void testTimeMethod_LongTaskTimer() { + timed.longCall(false); + metricExporter.assertCountDataPointsAtLeastOrEqual("longCall.active", null, 1); + assertEquals(0, metricExporter.get("longCall.active") + .tag("method", "longCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("extra", "tag") + .lastReadingDataPoint(LongPointData.class).getValue()); + } + + @Test + void testTimeMethod_LongTaskTimer_Failed() { + assertThrows(NullPointerException.class, () -> timed.longCall(true)); + + metricExporter.assertCountDataPointsAtLeastOrEqual("longCall.active", null, 1); + assertEquals(0, metricExporter.get("longCall.active") + .tag("method", "longCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("extra", "tag") + .lastReadingDataPoint(LongPointData.class).getValue()); + } + + @Test + void testTimeMethod_LongTaskTimer_Async() { + GuardedResult guardedResult = new GuardedResult(); + CompletableFuture completableFuture = timed.longAsyncCall(guardedResult); + guardedResult.complete(); + completableFuture.join(); + + metricExporter.assertCountDataPointsAtLeastOrEqual("async.longCall.active", null, 1); + assertEquals(0, metricExporter.get("async.longCall.active") + .tag("method", "longAsyncCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("extra", "tag") + .lastReadingDataPoint(LongPointData.class).getValue()); + } + + @Test + void testTimeMethod_LongTaskTimer_AsyncFailed() { + GuardedResult guardedResult = new GuardedResult(); + CompletableFuture completableFuture = timed.longAsyncCall(guardedResult); + guardedResult.complete(new NullPointerException()); + assertThrows(java.util.concurrent.CompletionException.class, () -> completableFuture.join()); + + metricExporter.assertCountDataPointsAtLeastOrEqual("async.longCall.active", null, 1); + assertEquals(0, metricExporter.get("async.longCall.active") + .tag("method", "longAsyncCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("extra", "tag") + .lastReadingDataPoint(LongPointData.class).getValue()); + } + + @Test + void testTimeMethod_LongTaskTimer_Uni() { + GuardedResult guardedResult = new GuardedResult(); + Uni uni = timed.longUniCall(guardedResult); + guardedResult.complete(); + uni.subscribe().asCompletionStage().join(); + + metricExporter.assertCountDataPointsAtLeastOrEqual("uni.longCall.active", null, 1); + assertEquals(0, metricExporter.get("uni.longCall.active") + .tag("method", "longUniCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("extra", "tag") + .lastReadingDataPoint(LongPointData.class).getValue()); + } + + @Test + void testTimeMethod_LongTaskTimer_UniFailed() throws InterruptedException { + GuardedResult guardedResult = new GuardedResult(); + Uni uni = timed.longUniCall(guardedResult); + guardedResult.complete(new NullPointerException()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> uni.subscribe().asCompletionStage().join()); + + // Was "uni.longCall" Now is "uni.longCall.active" and "uni.longCall.duration" + // Metric was executed but now there are no active tasks + + metricExporter.assertCountDataPointsAtLeastOrEqual("uni.longCall.active", null, 1); + assertEquals(0, metricExporter.get("uni.longCall.active") + .tag("method", "longUniCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("extra", "tag") + .lastReadingDataPoint(LongPointData.class).getValue()); + + assertEquals(0, metricExporter.get("uni.longCall.duration") + .tag("method", "longUniCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("extra", "tag") + .lastReadingDataPoint(DoublePointData.class).getValue()); + + } + + @Test + void testTimeMethod_repeatable() { + timed.repeatableCall(false); + + metricExporter.assertCountDataPointsAtLeastOrEqual("alpha", null, 1); + + assertEquals(1, metricExporter.get("alpha") + .tag("method", "repeatableCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "none") + .tag("extra", "tag") + .lastReadingPointsSize()); + + assertEquals(1, metricExporter.get("bravo") + .tag("method", "repeatableCall") + .tag("class", "io.quarkus.micrometer.opentelemetry.deployment.common.TimedResource") + .tag("exception", "none") + .tag("extra", "tag") + .lastReadingPointsSize()); + } + +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/RestClientUriParameterTest.java b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/RestClientUriParameterTest.java new file mode 100644 index 0000000000000..2b7a0609d878d --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/RestClientUriParameterTest.java @@ -0,0 +1,94 @@ +package io.quarkus.micrometer.opentelemetry.deployment.compatibility; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporter; +import io.quarkus.micrometer.opentelemetry.deployment.common.InMemoryMetricExporterProvider; +import io.quarkus.micrometer.opentelemetry.deployment.common.MetricDataFilter; +import io.quarkus.rest.client.reactive.Url; +import io.quarkus.test.QuarkusUnitTest; + +public class RestClientUriParameterTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot( + jar -> jar.addClasses(Resource.class, Client.class) + .addClasses(InMemoryMetricExporter.class, + InMemoryMetricExporterProvider.class, + MetricDataFilter.class) + .addClasses(InMemoryMetricExporter.class, InMemoryMetricExporterProvider.class, + MetricDataFilter.class) + .addAsResource(new StringAsset(InMemoryMetricExporterProvider.class.getCanonicalName()), + "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider")) + .withConfigurationResource("test-logging.properties") + .overrideConfigKey("quarkus.otel.metrics.exporter", "in-memory") + .overrideConfigKey("quarkus.otel.metric.export.interval", "300ms") + .overrideConfigKey("quarkus.redis.devservices.enabled", "false") + .overrideConfigKey("quarkus.rest-client.\"client\".url", "http://does-not-exist.io"); + + @Inject + InMemoryMetricExporter metricExporter; + + @RestClient + Client client; + + @ConfigProperty(name = "quarkus.http.test-port") + Integer testPort; + + @Test + public void testOverride() { + String result = client.getById("http://localhost:" + testPort, "bar"); + assertEquals("bar", result); + + metricExporter.assertCountDataPointsAtLeastOrEqual("http.client.requests", null, 1); + assertEquals(1, metricExporter.find("http.client.requests") + .tag("uri", "/example/{id}") + .lastReadingDataPoint(HistogramPointData.class).getCount()); + } + + @Path("/example") + @RegisterRestClient(baseUri = "http://dummy") + public interface Client { + + @GET + @Path("/{id}") + String getById(@Url String baseUri, @PathParam("id") String id); + } + + @Path("/example") + public static class Resource { + + @RestClient + Client client; + + @GET + @Path("/{id}") + @Produces(MediaType.TEXT_PLAIN) + public String example() { + return "bar"; + } + + @GET + @Path("/call") + @Produces(MediaType.TEXT_PLAIN) + public String call() { + return client.getById("http://localhost:8080", "1"); + } + } +} diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/resources/application.properties b/extensions/micrometer-opentelemetry/deployment/src/test/resources/application.properties new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/extensions/micrometer-opentelemetry/deployment/src/test/resources/test-logging.properties b/extensions/micrometer-opentelemetry/deployment/src/test/resources/test-logging.properties new file mode 100644 index 0000000000000..6eed6ab2596da --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/resources/test-logging.properties @@ -0,0 +1,4 @@ +#quarkus.log.category."io.quarkus.micrometer".level=DEBUG +quarkus.log.category."io.quarkus.bootstrap".level=INFO +#quarkus.log.category."io.quarkus.arc".level=DEBUG +quarkus.log.category."io.netty".level=INFO diff --git a/extensions/micrometer-opentelemetry/pom.xml b/extensions/micrometer-opentelemetry/pom.xml new file mode 100644 index 0000000000000..5c5333580541a --- /dev/null +++ b/extensions/micrometer-opentelemetry/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + io.quarkus + quarkus-extensions-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-micrometer-opentelemetry-parent + Quarkus - Micrometer to OpenTelemetry Bridge - Parent + pom + + + runtime + deployment + + + \ No newline at end of file diff --git a/extensions/micrometer-opentelemetry/runtime/pom.xml b/extensions/micrometer-opentelemetry/runtime/pom.xml new file mode 100644 index 0000000000000..15ae91441b756 --- /dev/null +++ b/extensions/micrometer-opentelemetry/runtime/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + + io.quarkus + quarkus-micrometer-opentelemetry-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-micrometer-opentelemetry + Quarkus - Micrometer to OpenTelemetry Bridge - Runtime + Micrometer registry implemented by the OpenTelemetry SDK + + + + io.quarkus + quarkus-core + + + + io.quarkus + quarkus-arc + + + + io.quarkus + quarkus-micrometer + + + + io.quarkus + quarkus-opentelemetry + + + + io.opentelemetry.instrumentation + opentelemetry-micrometer-1.5 + + + io.micrometer + micrometer-core + + + + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + + \ No newline at end of file diff --git a/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java b/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java new file mode 100644 index 0000000000000..d3f6029201e08 --- /dev/null +++ b/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java @@ -0,0 +1,48 @@ +package io.quarkus.micrometer.opentelemetry.runtime; + +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.util.TypeLiteral; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Metrics; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.micrometer.v1_5.OpenTelemetryMeterRegistry; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.opentelemetry.runtime.config.runtime.OTelRuntimeConfig; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class MicrometerOtelBridgeRecorder { + + public Function, Object> createBridge(OTelRuntimeConfig otelRuntimeConfig) { + + return new Function<>() { + @Override + public Object apply(SyntheticCreationalContext context) { + if (otelRuntimeConfig.sdkDisabled()) { + return null; + } + + Instance openTelemetry = context.getInjectedReference(new TypeLiteral<>() { + }); + + if (openTelemetry.isUnsatisfied()) { + return null; + } + + MeterRegistry meterRegistry = OpenTelemetryMeterRegistry.builder(openTelemetry.get()) + .setPrometheusMode(false) + .setMicrometerHistogramGaugesEnabled(true) + .setBaseTimeUnit(TimeUnit.MILLISECONDS) + .setClock(Clock.SYSTEM) + .build(); + Metrics.addRegistry(meterRegistry); + return meterRegistry; + } + }; + } +} diff --git a/extensions/micrometer-opentelemetry/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/micrometer-opentelemetry/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..4e45880a891fe --- /dev/null +++ b/extensions/micrometer-opentelemetry/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,17 @@ +name: "Micrometer OpenTelemetry Bridge" +artifact: ${project.groupId}:${project.artifactId}:${project.version} +metadata: + keywords: + - "micrometer" + - "metrics" + - "metric" + - "opentelemetry" + - "tracing" + - "logging" + - "monitoring" + guide: "https://quarkus.io/guides/telemetry-micrometer-to-opentelemetry" + categories: + - "observability" + status: "preview" + config: + - "quarkus.micrometer.otel." diff --git a/extensions/micrometer-opentelemetry/runtime/src/main/resources/application.properties b/extensions/micrometer-opentelemetry/runtime/src/main/resources/application.properties new file mode 100644 index 0000000000000..ba73d15105d5d --- /dev/null +++ b/extensions/micrometer-opentelemetry/runtime/src/main/resources/application.properties @@ -0,0 +1,3 @@ +# Tweak logs: +quarkus.log.category."io.opentelemetry.instrumentation.micrometer.v1_5.OpenTelemetryMeterRegistry".level=ERROR +quarkus.log.category."io.micrometer.core.instrument.composite.CompositeMeterRegistry".level=ERROR \ No newline at end of file diff --git a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/metric/MetricProcessor.java b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/metric/MetricProcessor.java index fcc57f9187165..9d698acc0b959 100644 --- a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/metric/MetricProcessor.java +++ b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/metric/MetricProcessor.java @@ -36,7 +36,12 @@ public class MetricProcessor { private static final DotName METRIC_PROCESSOR = DotName.createSimple(MetricProcessor.class.getName()); @BuildStep - void addNativeMonitoring(BuildProducer nativeMonitoring) { + void startJvmMetrics(BuildProducer nativeMonitoring, + BuildProducer additionalBeans) { + additionalBeans.produce(AdditionalBeanBuildItem.builder() + .setUnremovable() + .addBeanClass(JvmMetricsService.class) + .build()); nativeMonitoring.produce(new NativeMonitoringBuildItem(NativeConfig.MonitoringOption.JFR)); } @@ -48,7 +53,6 @@ UnremovableBeanBuildItem ensureProducersAreRetained( additionalBeans.produce(AdditionalBeanBuildItem.builder() .setUnremovable() .addBeanClass(MetricsProducer.class) - .addBeanClass(JvmMetricsService.class) .build()); IndexView index = indexBuildItem.getIndex(); diff --git a/extensions/pom.xml b/extensions/pom.xml index 7f86c1174b30f..33fbe2eed63b1 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -51,6 +51,7 @@ smallrye-fault-tolerance micrometer micrometer-registry-prometheus + micrometer-opentelemetry opentelemetry info observability-devservices diff --git a/integration-tests/micrometer-opentelemetry/pom.xml b/integration-tests/micrometer-opentelemetry/pom.xml new file mode 100644 index 0000000000000..5e438a2815700 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/pom.xml @@ -0,0 +1,203 @@ + + + 4.0.0 + + + io.quarkus + quarkus-integration-tests-parent + 999-SNAPSHOT + + + quarkus-integration-test-micrometer-opentelemetry + Quarkus - Integration Tests - Micrometer to OpenTelemetry Bridge + + + + io.quarkus + quarkus-micrometer-opentelemetry + 999-SNAPSHOT + + + + + io.quarkus + quarkus-rest-jackson + + + + + io.quarkus + quarkus-rest-client-jackson + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + + io.opentelemetry + opentelemetry-sdk-testing + + + org.assertj + assertj-core + compile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + io.quarkus + quarkus-micrometer-opentelemetry-deployment + 999-SNAPSHOT + pom + test + + + * + * + + + + + io.quarkus + quarkus-rest-jackson-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-rest-client-jackson-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + false + + + + + + + + + native-image + + + native + + + + + true + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + ${native.surefire.skip} + + false + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + false + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + + + + + + diff --git a/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/AppResource.java b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/AppResource.java new file mode 100644 index 0000000000000..b7c1e43359090 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/AppResource.java @@ -0,0 +1,26 @@ +package io.quarkus.micrometer.opentelemetry; + +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 jakarta.ws.rs.core.Response; + +import io.quarkus.micrometer.opentelemetry.services.CountedBean; + +@Path("") +@Produces(MediaType.APPLICATION_JSON) +public class AppResource { + + @Inject + CountedBean countedBean; + + @Path("/count") + @GET + public Response count(@QueryParam("fail") boolean fail) { + countedBean.countAllInvocations(fail); + return Response.ok().build(); + } +} diff --git a/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/ExporterResource.java b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/ExporterResource.java new file mode 100644 index 0000000000000..7980baf877005 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/ExporterResource.java @@ -0,0 +1,125 @@ +package io.quarkus.micrometer.opentelemetry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricExporter; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.semconv.SemanticAttributes; + +@Path("") +public class ExporterResource { + @Inject + InMemorySpanExporter inMemorySpanExporter; + @Inject + InMemoryMetricExporter inMemoryMetricExporter; + @Inject + InMemoryLogRecordExporter inMemoryLogRecordExporter; + + @GET + @Path("/reset") + public Response reset() { + inMemorySpanExporter.reset(); + inMemoryMetricExporter.reset(); + inMemoryLogRecordExporter.reset(); + return Response.ok().build(); + } + + /** + * Will exclude export endpoint related traces + */ + @GET + @Path("/export") + public List exportTraces() { + return inMemorySpanExporter.getFinishedSpanItems() + .stream() + .filter(sd -> !sd.getName().contains("export") && !sd.getName().contains("reset")) + .collect(Collectors.toList()); + } + + /** + * Export metrics with optional filtering by name and target + */ + @GET + @Path("/export/metrics") + public List exportMetrics(@QueryParam("name") String name, @QueryParam("target") String target) { + return Collections.unmodifiableList(new ArrayList<>( + inMemoryMetricExporter.getFinishedMetricItems().stream() + .filter(metricData -> name == null ? true : metricData.getName().equals(name)) + .filter(metricData -> target == null ? true + : metricData.getData() + .getPoints().stream() + .anyMatch(point -> isPathFound(target, point.getAttributes()))) + .collect(Collectors.toList()))); + } + + /** + * Will exclude Quarkus startup logs + */ + @GET + @Path("/export/logs") + public List exportLogs(@QueryParam("body") String message) { + if (message == null) { + return inMemoryLogRecordExporter.getFinishedLogRecordItems().stream() + .collect(Collectors.toList()); + } + return inMemoryLogRecordExporter.getFinishedLogRecordItems().stream() + .filter(logRecordData -> logRecordData.getBody().asString().equals(message)) + .collect(Collectors.toList()); + } + + private static boolean isPathFound(String path, Attributes attributes) { + if (path == null) { + return true;// any match + } + Object value = attributes.asMap().get(AttributeKey.stringKey(SemanticAttributes.HTTP_ROUTE.getKey())); + if (value == null) { + return false; + } + return value.toString().equals(path); + } + + @ApplicationScoped + static class InMemorySpanExporterProducer { + @Produces + @Singleton + InMemorySpanExporter inMemorySpanExporter() { + return InMemorySpanExporter.create(); + } + } + + @ApplicationScoped + static class InMemoryMetricExporterProducer { + @Produces + @Singleton + InMemoryMetricExporter inMemoryMetricsExporter() { + return InMemoryMetricExporter.create(); + } + } + + @ApplicationScoped + static class InMemoryLogRecordExporterProducer { + @Produces + @Singleton + public InMemoryLogRecordExporter createInMemoryExporter() { + return InMemoryLogRecordExporter.create(); + } + } +} diff --git a/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/SimpleResource.java b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/SimpleResource.java new file mode 100644 index 0000000000000..a8b1fb7d008f9 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/SimpleResource.java @@ -0,0 +1,163 @@ +package io.quarkus.micrometer.opentelemetry; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opentelemetry.api.baggage.Baggage; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.context.Scope; +import io.quarkus.micrometer.opentelemetry.services.TraceData; +import io.quarkus.micrometer.opentelemetry.services.TracedService; + +@Path("") +@Produces(MediaType.APPLICATION_JSON) +public class SimpleResource { + + private static final Logger LOG = LoggerFactory.getLogger(SimpleResource.class); + + @RegisterRestClient(configKey = "simple") + public interface SimpleClient { + @Path("") + @GET + TraceData noPath(); + + @Path("/") + @GET + TraceData slashPath(); + + @Path("/from-baggage") + @GET + TraceData fromBaggagePath(); + } + + @Inject + TracedService tracedService; + + @Inject + @RestClient + SimpleClient simpleClient; + + @Inject + Baggage baggage; + + @Inject + Meter meter; + + @GET + public TraceData noPath() { + TraceData data = new TraceData(); + data.message = "No path trace"; + return data; + } + + @GET + @Path("/nopath") + public TraceData noPathClient() { + return simpleClient.noPath(); + } + + @GET + @Path("/slashpath") + public TraceData slashPathClient() { + return simpleClient.slashPath(); + } + + @GET + @Path("/slashpath-baggage") + public TraceData slashPathBaggageClient() { + try (Scope scope = baggage.toBuilder() + .put("baggage-key", "baggage-value") + .build() + .makeCurrent()) { + return simpleClient.fromBaggagePath(); + } + } + + @GET + @Path("/from-baggage") + public TraceData fromBaggageValue() { + TraceData data = new TraceData(); + data.message = baggage.getEntryValue("baggage-key"); + return data; + } + + @GET + @Path("/direct") + public TraceData directTrace() { + LOG.info("directTrace called"); + TraceData data = new TraceData(); + data.message = "Direct trace"; + return data; + } + + @GET + @Path("/direct-metrics") + public TraceData directTraceWithMetrics() { + meter.counterBuilder("direct-trace-counter") + .setUnit("items") + .setDescription("A counter of direct traces") + .build() + .add(1, Attributes.of(AttributeKey.stringKey("key"), "low-cardinality-value")); + TraceData data = new TraceData(); + data.message = "Direct trace"; + return data; + } + + @GET + @Path("/chained") + public TraceData chainedTrace() { + LOG.info("chainedTrace called"); + TraceData data = new TraceData(); + data.message = tracedService.call(); + + return data; + } + + @GET + @Path("/deep/path") + public TraceData deepUrlPathTrace() { + TraceData data = new TraceData(); + data.message = "Deep url path"; + + return data; + } + + @GET + @Path("/param/{paramId}") + public TraceData pathParameters(@PathParam("paramId") String paramId) { + TraceData data = new TraceData(); + data.message = "ParameterId: " + paramId; + + return data; + } + + @GET + @Path("/exception") + public String exception() { + var exception = new RuntimeException("Exception!"); + StackTraceElement[] trimmedStackTrace = new StackTraceElement[2]; + System.arraycopy(exception.getStackTrace(), 0, trimmedStackTrace, 0, trimmedStackTrace.length); + exception.setStackTrace(trimmedStackTrace); + LOG.error("Oh no {}", exception.getMessage(), exception); + return "Oh no! An exception"; + } + + @GET + @Path("/suppress-app-uri") + public TraceData suppressAppUri() { + TraceData traceData = new TraceData(); + traceData.message = "Suppress me!"; + return traceData; + } +} diff --git a/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/CountedBean.java b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/CountedBean.java new file mode 100644 index 0000000000000..58b5e34a14be1 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/CountedBean.java @@ -0,0 +1,27 @@ +package io.quarkus.micrometer.opentelemetry.services; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.aop.MeterTag; + +@ApplicationScoped +public class CountedBean { + @Counted(value = "metric.none", recordFailuresOnly = true) + public void onlyCountFailures() { + } + + @Counted(value = "metric.all", extraTags = { "extra", "tag" }) + public void countAllInvocations(@MeterTag(key = "do_fail", resolver = TestValueResolver.class) boolean fail) { + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + } + + @Counted(description = "nice description") + public void emptyMetricName(@MeterTag boolean fail) { + if (fail) { + throw new NullPointerException("Failed on purpose"); + } + } +} diff --git a/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/ManualHistogram.java b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/ManualHistogram.java new file mode 100644 index 0000000000000..a0ab2983351b4 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/ManualHistogram.java @@ -0,0 +1,28 @@ +package io.quarkus.micrometer.opentelemetry.services; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; + +@ApplicationScoped +public class ManualHistogram { + @Inject + MeterRegistry registry; + + public void recordHistogram() { + DistributionSummary summary = DistributionSummary.builder("testSummary") + .description("This is a test distribution summary") + .baseUnit("things") + .tags("tag", "value") + .serviceLevelObjectives(1, 10, 100, 1000) + .distributionStatisticBufferLength(10) + .register(registry); + + summary.record(0.5); + summary.record(5); + summary.record(50); + summary.record(500); + } +} diff --git a/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TestValueResolver.java b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TestValueResolver.java new file mode 100644 index 0000000000000..621a1d478bda7 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TestValueResolver.java @@ -0,0 +1,13 @@ +package io.quarkus.micrometer.opentelemetry.services; + +import jakarta.inject.Singleton; + +import io.micrometer.common.annotation.ValueResolver; + +@Singleton +public class TestValueResolver implements ValueResolver { + @Override + public String resolve(Object parameter) { + return "prefix_" + parameter; + } +} diff --git a/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TraceData.java b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TraceData.java new file mode 100644 index 0000000000000..37a2418286da8 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TraceData.java @@ -0,0 +1,5 @@ +package io.quarkus.micrometer.opentelemetry.services; + +public class TraceData { + public String message; +} diff --git a/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TracedService.java b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TracedService.java new file mode 100644 index 0000000000000..24fec4abe9d80 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/java/io/quarkus/micrometer/opentelemetry/services/TracedService.java @@ -0,0 +1,20 @@ +package io.quarkus.micrometer.opentelemetry.services; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opentelemetry.instrumentation.annotations.WithSpan; + +@ApplicationScoped +public class TracedService { + + private static final Logger LOG = LoggerFactory.getLogger(TracedService.class); + + @WithSpan + public String call() { + LOG.info("Chained trace called"); + return "Chained trace"; + } +} diff --git a/integration-tests/micrometer-opentelemetry/src/main/resources/application.properties b/integration-tests/micrometer-opentelemetry/src/main/resources/application.properties new file mode 100644 index 0000000000000..ecdb4ab977d99 --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/main/resources/application.properties @@ -0,0 +1,11 @@ +# Setting these for tests explicitly. Not required in normal application +quarkus.application.name=micrometer-opentelemetry-integration-test +quarkus.application.version=999-SNAPSHOT + +# speed up tests +quarkus.otel.bsp.schedule.delay=100 +quarkus.otel.bsp.export.timeout=5s +quarkus.otel.metric.export.interval=100ms + + + diff --git a/integration-tests/micrometer-opentelemetry/src/test/java/io/quarkus/micrometer/opentelemetry/MicrometerCounterInterceptorTest.java b/integration-tests/micrometer-opentelemetry/src/test/java/io/quarkus/micrometer/opentelemetry/MicrometerCounterInterceptorTest.java new file mode 100644 index 0000000000000..2e42626213acd --- /dev/null +++ b/integration-tests/micrometer-opentelemetry/src/test/java/io/quarkus/micrometer/opentelemetry/MicrometerCounterInterceptorTest.java @@ -0,0 +1,63 @@ +package io.quarkus.micrometer.opentelemetry; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.restassured.RestAssured.get; +import static io.restassured.RestAssured.given; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.common.mapper.TypeRef; + +@QuarkusTest +public class MicrometerCounterInterceptorTest { + + @BeforeEach + @AfterEach + void reset() { + given().get("/reset").then().statusCode(HTTP_OK); + } + + @Test + void testCountAllMetrics_MetricsOnSuccess() { + given() + .when() + .get("/count") + .then() + .statusCode(200); + + await().atMost(5, SECONDS).until(() -> getMetrics("metric.all").size() > 1); + + List> metrics = getMetrics("metric.all"); + + Double value = (Double) ((Map) ((List) ((Map) (getMetrics("metric.all") + .get(metrics.size() - 1) + .get("data"))) + .get("points")) + .get(0)) + .get("value"); + assertThat(value).isEqualTo(1d); + } + + private List> getMetrics(String metricName) { + return given() + .when() + .queryParam("name", metricName) + .get("/export/metrics") + .body().as(new TypeRef<>() { + }); + } + + private List> getSpans() { + return get("/export").body().as(new TypeRef<>() { + }); + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 6376d450f75fa..f27f1c0357139 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -358,6 +358,7 @@ micrometer-mp-metrics micrometer-prometheus micrometer-security + micrometer-opentelemetry opentelemetry opentelemetry-quickstart opentelemetry-spi