From 3bfddac4d3c0f5979743e3e30aa74e0f590bf933 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 + devtools/bom-descriptor-json/pom.xml | 13 + docs/pom.xml | 13 + ...telemetry-micrometer-to-opentelemetry.adoc | 233 +++++++++++++ .../deployment/pom.xml | 134 ++++++++ ...eterOTelBridgeConfigBuilderCustomizer.java | 18 + .../MicrometerOtelBridgeProcessor.java | 77 +++++ ...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 | 180 ++++++++++ .../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 | 241 +++++++++++++ .../compatibility/JvmCompatibilityTest.java | 105 ++++++ .../MicrometerCounterInterceptorTest.java | 125 +++++++ .../MicrometerTimedInterceptorTest.java | 321 ++++++++++++++++++ .../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 | 45 +++ .../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 + 48 files changed, 3277 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 ee6584b2b45f4..037c313be9085 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 b2aea36b34d60..c05cf0a5cfa2d 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -3199,6 +3199,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/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index d93441834129b..32f47584e0fe6 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -1591,6 +1591,19 @@ + + io.quarkus + quarkus-micrometer-opentelemetry + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-micrometer-registry-prometheus diff --git a/docs/pom.xml b/docs/pom.xml index 8dd5d654a0a7c..3d063ddcf8335 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -1602,6 +1602,19 @@ + + io.quarkus + quarkus-micrometer-opentelemetry-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-micrometer-registry-prometheus-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..10f631c411194 --- /dev/null +++ b/docs/src/main/asciidoc/telemetry-micrometer-to-opentelemetry.adoc @@ -0,0 +1,233 @@ +//// +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 to 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 extension allows the normal use of the Micrometer API, but have the metrics handled by the OpenTelemetry extension. + +As an example, the `@Timed` annotation from Micrometer is used to measure the execution time of a method: +```java +import io.micrometer.core.annotation.Timed; +//... +@Timed(name = "timer_metric") +public String timer() { + return "OK"; +} +``` +The output telemetry data is handled by the OpenTelemetry SDK and sent by the `quarkus-opentelemetry` extension exporter using the OTLP protocol. + +This reduces the overhead of having an independent Micrometer registry plus the OpenTelemetry SDK in memory for the same application when both `quarkus-micrometer` and `quarkus-opentelemetry` extensions are used independently. + +*The OpenTelemetry SDK will handle all metrics.* Either Micrometer metrics (manual or automatic) and OpenTelemetry Metrics can be used . All are available with this single extension. + +All the configurations from the OpenTelemetry and Micrometer extensions are available with `quarkus-micrometer-opentelemetry`. + +The bridge is more than the simple OTLP registry found in Quarkiverse. In this extension, the OpenTelemetry SDK provides a Micrometer registry implementation 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 + +When the extension is present, 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 can be collected by Micrometer. + +Specific automatic Micrometer metrics are all disabled by default and can be enabled by setting, for example in the case of JVM metrics: +``` +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 service level objectives (slo) are set to `true` when creating a `DistributionSummary`, an additional 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. The OpenTelemetry Metrics are based on the https://opentelemetry.io/docs/concepts/semantic-conventions/[`OpenTelemetry Semantic Conventions`] and are still under active development (early 2025). Micrometer metrics convention format is around for a long time and has not changed much. + +When 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 missing from 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..fccb0b9c39bba --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOTelBridgeConfigBuilderCustomizer.java @@ -0,0 +1,18 @@ +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.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..3429ce4fdfd54 --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java @@ -0,0 +1,77 @@ +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.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) { + + if (otelRuntimeConfig.sdkDisabled()) { + return; // No point in creating the bridge if the SDK is disabled + } + + 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..2a01838c480df --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/common/InMemoryMetricExporter.java @@ -0,0 +1,180 @@ +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.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("uri") && + 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("uri")); + 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..f91b4ffc96bce --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/HttpCompatibilityTest.java @@ -0,0 +1,241 @@ +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 static java.util.concurrent.TimeUnit.SECONDS; + +import java.util.Comparator; +import java.util.List; + +import jakarta.inject.Inject; + +import org.awaitility.Awaitility; +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=100ms\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 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); + + // 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); + + Awaitility.await().atMost(10, SECONDS) + .untilAsserted(() -> { + final List metricDataList = metricExporter.getFinishedMetricItems("http.server.requests", null); + final MetricData metricData = metricDataList.get(metricDataList.size() - 1); // get last collected + assertServerMetrics(metricData); + }); + + final List metricDataList = metricExporter.getFinishedMetricItems("http.server.requests", null); + final MetricData metricData = metricDataList.stream() + .max(Comparator.comparingInt(data -> data.getData().getPoints().size())) + .get(); + + 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(); + + // 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); + + Awaitility.await().atMost(10, SECONDS) + .untilAsserted(() -> { + final MetricData clientMetricData = clientMetricDataList.get(clientMetricDataList.size() - 1); // get last collected + 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( + // "uri" not following conventions and should be "http.route" + equalTo(URI, "/pong/{message}"), + // Method not following conventions and should be "http.request.method" + equalTo(METHOD, "GET"), + // 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")))); + }); + } + + private static void assertServerMetrics(MetricData metricData) { + 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( + // "uri" not following conventions and should be "http.route" + equalTo(URI, "/vertx/item/{id}"), + // method not following conventions and should be "http.request.method" + equalTo(METHOD, "GET"), + // 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")))); + } +} 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..41fec6cf81aab --- /dev/null +++ b/extensions/micrometer-opentelemetry/deployment/src/test/java/io/quarkus/micrometer/opentelemetry/deployment/compatibility/MicrometerTimedInterceptorTest.java @@ -0,0 +1,321 @@ +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.BeforeEach; +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", "100ms") + .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; + + @BeforeEach + void setUp() { + metricExporter.reset(); + } + + @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..017a32d2cc281 --- /dev/null +++ b/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java @@ -0,0 +1,45 @@ +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, MeterRegistry> createBridge( + OTelRuntimeConfig otelRuntimeConfig) { + + return new Function<>() { + @Override + public MeterRegistry apply(SyntheticCreationalContext context) { + Instance openTelemetry = context.getInjectedReference(new TypeLiteral<>() { + }); + + if (openTelemetry.isUnsatisfied()) { + throw new IllegalStateException("OpenTelemetry instance not found"); + } + + 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 55f67dc439690..69bc1f6b44788 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -359,6 +359,7 @@ micrometer-mp-metrics micrometer-prometheus micrometer-security + micrometer-opentelemetry opentelemetry opentelemetry-quickstart opentelemetry-spi