diff --git a/bom/pom.xml b/bom/pom.xml index 60d895a39ed..3db507a7651 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -738,6 +738,11 @@ helidon-microprofile-rest-client ${helidon.version} + + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + ${helidon.version} + diff --git a/docs/src/main/asciidoc/includes/pages.adoc b/docs/src/main/asciidoc/includes/pages.adoc index 71a081a3ec0..d90a6ed13f3 100644 --- a/docs/src/main/asciidoc/includes/pages.adoc +++ b/docs/src/main/asciidoc/includes/pages.adoc @@ -27,5 +27,5 @@ ifdef::mp-flavor[] endif::[] :webclient-page: {rootdir}/se/webclient.adoc -:restclient-page: {rootdir}/mp/restclient.adoc +:restclient-page: {rootdir}/mp/restclient/restclient.adoc :cli-page: {rootdir}/about/cli.adoc diff --git a/docs/src/main/asciidoc/mp/introduction.adoc b/docs/src/main/asciidoc/mp/introduction.adoc index 383f49cf176..4016861e58c 100644 --- a/docs/src/main/asciidoc/mp/introduction.adoc +++ b/docs/src/main/asciidoc/mp/introduction.adoc @@ -1,6 +1,6 @@ /////////////////////////////////////////////////////////////////////////////// - Copyright (c) 2019, 2023 Oracle and/or its affiliates. + Copyright (c) 2019, 2024 Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -121,7 +121,7 @@ overhead server built on Java virtual threads. | link:{microprofile-rs-operators-spec-url}[{version-lib-microprofile-rs-operators-api}] | Control flow and error processing for event streams -| xref:{rootdir}/mp/restclient.adoc[MicroProfile REST Client] +| xref:{rootdir}/mp/restclient/restclient.adoc[MicroProfile REST Client] | link:{microprofile-rest-client-spec-url}[{version-lib-microprofile-rest-client}] | Type-safe API for RESTful Web Services diff --git a/docs/src/main/asciidoc/mp/jaxrs/jaxrs-client.adoc b/docs/src/main/asciidoc/mp/jaxrs/jaxrs-client.adoc index 752a59460bd..ec0fa91f3cd 100644 --- a/docs/src/main/asciidoc/mp/jaxrs/jaxrs-client.adoc +++ b/docs/src/main/asciidoc/mp/jaxrs/jaxrs-client.adoc @@ -39,7 +39,7 @@ include::{rootdir}/includes/mp.adoc[] The Jakarta REST Client defines a programmatic API to access REST resources. This API sits at a higher level than traditional HTTP client APIs and provides full integration with server-side API concepts like providers. It differs -from the xref:../restclient.adoc[Rest Client API] in that it does not support +from the xref:../restclient/restclient.adoc[Rest Client API] in that it does not support annotations or proxies, but instead uses builders and a fluent API to create and execute requests. diff --git a/docs/src/main/asciidoc/mp/restclient.adoc b/docs/src/main/asciidoc/mp/restclient/restclient.adoc similarity index 90% rename from docs/src/main/asciidoc/mp/restclient.adoc rename to docs/src/main/asciidoc/mp/restclient/restclient.adoc index 63f6e6a7244..abafcbf7e48 100644 --- a/docs/src/main/asciidoc/mp/restclient.adoc +++ b/docs/src/main/asciidoc/mp/restclient/restclient.adoc @@ -21,7 +21,7 @@ :feature-name: MicroProfile Rest Client :microprofile-bundle: true :keywords: helidon, rest, client, microprofile, micro-profile -:rootdir: {docdir}/.. +:rootdir: {docdir}/../.. include::{rootdir}/includes/mp.adoc[] @@ -43,8 +43,11 @@ Helidon will automatically create a _proxy_ class for the interface and map loca For more information, see link:{microprofile-rest-client-spec-url}[Rest Client For MicroProfile Specification]. +You can also use metrics annotations on your Rest Client methods as described in xref:restclientmetrics.adoc[this related page.] + include::{rootdir}/includes/dependencies.adoc[] +// tag::helidon-restclient-dep[] [source,xml] ---- @@ -52,7 +55,7 @@ include::{rootdir}/includes/dependencies.adoc[] helidon-microprofile-rest-client ---- - +// end::helidon-restclient-dep[] == API [cols="1,2"] |=== @@ -86,7 +89,7 @@ the provided configuration. [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_1, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_1, indent=0] ---- The `RestClientBuilder` interface extends the `Configurable` interface from Jakarta REST (JAX-RS), @@ -95,7 +98,7 @@ enabling direct registration of _providers_ such as filters, param converters, e [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_2, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_2, indent=0] ---- === Creating a New Client Using CDI @@ -107,7 +110,7 @@ to access the service. [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_3, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_3, indent=0] ---- Any Jakarta REST (JAX-RS) providers for a client can be registered using the (repeatable) @@ -116,7 +119,7 @@ Any Jakarta REST (JAX-RS) providers for a client can be registered using the (re [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_4, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_4, indent=0] ---- Once a client interface is annotated, it can be injected into any CDI bean. @@ -126,7 +129,7 @@ All properties in annotation `RegisterRestClient` can be overridden via configur [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_5, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_5, indent=0] ---- == Configuration @@ -189,14 +192,15 @@ Configuration options affecting CDI and programmatically created clients: |=== == Examples -To be able to run and test this example, use the xref:guides/quickstart.adoc[Helidon MP examples/quickstarts]. +To be able to run and test this example, use the xref:../guides/quickstart.adoc[Helidon MP examples/quickstarts]. Add a dependency on the Helidon Rest Client implementation and create the following client interface: [source,java] .client interface ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_6, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_6, indent=0] ---- +[[example-after-client-interface]] Then create a runnable method as described in <>, but with baseUri `http://localhost:8080/greet` and the above interface. diff --git a/docs/src/main/asciidoc/mp/restclient/restclientmetrics.adoc b/docs/src/main/asciidoc/mp/restclient/restclientmetrics.adoc new file mode 100644 index 00000000000..7095428f1fa --- /dev/null +++ b/docs/src/main/asciidoc/mp/restclient/restclientmetrics.adoc @@ -0,0 +1,194 @@ +/////////////////////////////////////////////////////////////////////////////// + + Copyright (c) 2024 Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +/////////////////////////////////////////////////////////////////////////////// + += Rest Client Metrics +:description: Helidon MP Rest Client Metrics +:feature-name: MicroProfile Rest Client Metrics +:microprofile-bundle: true +:keywords: helidon, rest, client, microprofile, micro-profile, metrics +:rootdir: {docdir}/../.. + +include::{rootdir}/includes/mp.adoc[] + +== Contents + +- <> +- <> +- <> +- <> +- <> +- <> + +== Overview +Helidon supports MicroProfile REST Client metrics by registering metrics automatically when developers add MicroProfile Metrics annotations to REST client interfaces and methods. + +MicroProfile neither mandates nor specifies how metrics and the REST client work together. Support in Helidon for metrics on REST clients uses the MicroProfile Metrics spec for inspiration where appropriate. + +For more information about support for REST clients in Helidon see xref:restclient.adoc[REST Client]. + +include::{rootdir}/includes/dependencies.adoc[] + +// tag::helidon-restclientmetrics-dep[] +[source,xml] +---- + + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + +---- +// end::helidon-restclientmetrics-dep[] + +== Usage +Add the MicroProfile Metrics `@Counted` and `@Timed` annotations to REST client interfaces and interface methods to trigger counting or timing, respectively, of REST client method invocations. + +Helidon determines metric names according to the link:https://download.eclipse.org/microprofile/microprofile-metrics-5.1.1/microprofile-metrics-spec-5.1.1.html#annotated-naming-convention[MicroProfile Metrics naming convention] and supports the following metrics naming features: + +* absolute and relative names +* explicit and inferred names +* type-level annotations + +When you place annotations at the type level of a REST client interface Helidon registers _different_ metrics for each of the REST methods on the interface. This is the same behavior as in normal MicroProfile Metrics when you add metrics annotations at the type level. + +When you use the annotations at the type level on a superinterface Helidon acts as if those annotations appear at the type-level of any REST client subinterface which extends the superinterface. In keeping with the naming conventions enforced by the MicroProfile Metrics TCK, relative metric names use the _subinterface_ name not the declaring interface name. + +(Note that the +MicroProfile Metrics specification states that the _declaring_ class name is used, while as written the MicroProfile Metrics TCK requires that implementations use the _subclass_ name. For consistency the Helidon REST client metrics implementation follows the enforced metrics TCK behavior.) + +=== Understanding How and When Helidon Registers REST Client Metrics + +Helidon registers the metrics associated with a REST client interface when that interface becomes known to Helidon as a REST client. + +The link:https://download.eclipse.org/microprofile/microprofile-rest-client-3.0/microprofile-rest-client-spec-3.0.html#_microprofile_rest_client[MicroProfile REST Client spec] describes how your application can inject a REST client interface or prepare it programmatically. Either action makes the REST client known to Helidon, at which time Helidon registers the metrics associated with that interface's methods. As a result, depending on how your application works, REST client metrics might be registered well after your code initially starts up. + +=== Using REST Client Metrics in Standalone Clients vs. in Servers +Helidon registers and updates REST client metrics whether the REST client is standalone or is embedded in a Helidon server. + +Helidon _does not_ provide a `/metrics` endpoint for standalone clients, nor does it provide any built-in way to transmit metrics data from a client to a backend system. If needed, you can write your client code to access the application `MetricRegistry` and retrieve the REST client metrics Helidon has registered. + +In contrast, when REST clients run inside Helidon servers the REST client metrics for REST clients known to Helidon appear in the `/metrics` output. + +=== Turning on Logging +Set `io.helidon.microprofile.restclientmetrics.level=DEBUG` in your logging settings to see some of the inner workings of the REST client metrics implementation. + +During start-up the logging reports analysis of candidate REST client interfaces and the creation of metric registration entries, including the metric annotation and where Helidon found each. + +When a REST client is made known to Helidon the logging reports the actual registration of the metrics derived from that REST client interface. + +== API +Use the following annotations from `org.eclipse.microprofile.metrics.annotation` listed in the following table to trigger REST client metrics. +[cols="1,2"] +|=== +| Annotation | Description +| `@Counted` | Counts the invocations of a REST client method. +| `@Timed` | Times the invocations of a REST client method. +|=== +Type-level annotations trigger registration of separate metrics for each REST client method in the REST client interface. + +== Configuration +Optional configuration options: +[cols="3,3,2,5"] + +|=== +|key |type |default value |description + +|`enabled` | string | `true` | Whether to use REST client metrics. +|=== +The `enabled` configuration setting allows developers to build REST client metrics into an application while permitting end users to disable the feature at their discretion. + +== Examples +This example is similar to the xref:restclient.adoc#_examples[Helidon REST Client doc example] which starts with the xref:../guides/quickstart.adoc[Helidon MP QuickStart example]. + +This sample app adds a new resource which mimics the functionality of the `GreetResource` but delegates each incoming request to its counterpart on the `GreetResource` using a REST client interface for that `GreetResource`. In short, the example application delegates to itself. Of course no production application would operate this way, but this contrived situation helps illustrate how to use REST client metrics simply with a single runnable project. + +To create this REST client metrics example follow these steps. + +1. Starting with the Helidon MP QuickStart example, add dependencies for both the Helidon REST client component and the Helidon REST client metrics component, as shown below. ++ +include::restclient.adoc[tag=helidon-restclient-dep] ++ +include::restclientmetrics.adoc[tag=helidon-restclientmetrics-dep] +2. Add the following REST client interface which includes MicroProfile Metrics annotations to count and time various REST client method invocations. ++ +[source,java] +---- +include::{sourcedir}/mp/restclient/RestclientMetricsSnippets.java[tag=snippet_1, indent=0] +---- +<1> Times all outbound method invocations using separate timers for each method. +<2> Counts the number of times a request is sent to get the default greeting message. +3. Add a new resource class, similar to the `GreetService` resource class, but which delegates all incoming requests using the REST client. ++ +[source,java] +---- +include::{sourcedir}/mp/restclient/RestclientMetricsSnippets.java[tag=snippet_2, indent=0] +---- +<1> Holds the prepared REST client for use by the delegating methods. +<2> Prepares the REST client. The example shows only one of many ways of doing this step. +<3> Each delegating method invokes the corresponding REST client method and returns the result from it. ++ +By default, resource classes such as `DelegatingResource` are instantiated for each incoming request, but generally a Helidon server making outbound requests reuses the client data structures and connections. To create and reuse only a single REST client instance this example resource uses the Helidon `LazyValue` utility class so even as the system creates multiple instances of `DelegatingResource` they all reuse the same REST client. +4. Build and run the application. ++ +[source,bash] +---- +mvn clean package +java -jar target/helidon-quickstart-mp.jar +---- +5. Access the delegating endpoints. ++ +[source,bash] +---- +curl http://localhost:8080/delegate +curl http://localhost:8080/delegate +curl http://localhost:8080/delegate/Joe +---- +6. Retrieve the application metrics for the `getDefaultMessage` operation. ++ +[source,bash] +---- +curl 'http://localhost:8080/metrics?scope=application' | grep getDefault +---- +7. Look for two types of metrics: + a. Counter: ++ +[source,list] +---- +# TYPE io_helidon_examples_quickstart_mp_GreetRestClient_getDefaultMessage_total counter +io_helidon_examples_quickstart_mp_GreetRestClient_getDefaultMessage_total{mp_scope="application",} 2.0 +---- ++ +This is the counter resulting from the `@Counted` annotation on the `getDefaultMessage` method of the REST client interface. The name is relative to the annotated method's class and is automatically set to the method name because neither `name` nor `absolute` were specified with the annotation. +b. Timer: ++ +[source,list] +---- +# TYPE timedGreet_getDefaultMessage_seconds summary +timedGreet_getDefaultMessage_seconds{mp_scope="application",quantile="0.5",} 0.003407872 +timedGreet_getDefaultMessage_seconds{mp_scope="application",quantile="0.75",} 0.092143616 +timedGreet_getDefaultMessage_seconds_count{mp_scope="application",} 2.0 +---- ++ +This excerpt shows the output for only one timer, but the full output includes timers for each method. ++ +The `@Timed` annotation at the type level triggers the registration of timers for each REST method in the REST client interface. The `name` setting overrides the default of the type name, and the `absolute` setting means the selected name _is not_ relative to the fully-qualified class name. + +== Reference + +* xref:restclient.adoc[Helidon REST Client documentation] +* link:{microprofile-rest-client-spec-url}[MicroProfile RestClient specification] +* link:{microprofile-metrics-spec-url}[MicroProfile Metrics specification] + diff --git a/docs/src/main/asciidoc/sitegen.yaml b/docs/src/main/asciidoc/sitegen.yaml index 35f50b37b33..6eba3acd532 100644 --- a/docs/src/main/asciidoc/sitegen.yaml +++ b/docs/src/main/asciidoc/sitegen.yaml @@ -234,12 +234,15 @@ backend: sources: - "engine.adoc" - "rsoperators.adoc" - - type: "PAGE" + - type: "MENU" title: "REST Client" - source: "restclient.adoc" + dir: "restclient" glyph: type: "icon" value: "airplay" + sources: + - "restclient.adoc" + - "restclientmetrics.adoc" - type: "PAGE" title: "Scheduling" source: "scheduling.adoc" diff --git a/docs/src/main/java/io/helidon/docs/mp/restclient/RestclientMetricsSnippets.java b/docs/src/main/java/io/helidon/docs/mp/restclient/RestclientMetricsSnippets.java new file mode 100644 index 00000000000..46574e1322d --- /dev/null +++ b/docs/src/main/java/io/helidon/docs/mp/restclient/RestclientMetricsSnippets.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.docs.mp; + +import java.net.URI; + +import io.helidon.common.LazyValue; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.metrics.annotation.Counted; +import org.eclipse.microprofile.metrics.annotation.Timed; +import org.eclipse.microprofile.rest.client.RestClientBuilder; + +import static io.helidon.docs.mp.RestclientMetricsSnippets.Snippet1.GreetRestClient; + +@SuppressWarnings("ALL") +class RestclientMetricsSnippets { + + // stub + static interface GreetingMessage { } + + + class Snippet1 { + + // tag::snippet_1[] + @Path("/greet") + @Timed(name = "timedGreet", absolute = true) // <1> + public interface GreetRestClient { + + @Counted // <2> + @GET + @Produces(MediaType.APPLICATION_JSON) + GreetingMessage getDefaultMessage(); + + @Path("/{name}") + @GET + @Produces(MediaType.APPLICATION_JSON) + GreetingMessage getMessage(@PathParam("name") String name); + + @Path("/greeting") + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + Response updateGreeting(GreetingMessage message); + } + // end::snippet_1[] + } + + class Snippet2 { + + // tag::snippet_2[] + @Path("/delegate") + public class DelegatingResource { + + private static LazyValue greetRestClient = LazyValue.create(DelegatingResource::prepareClient); // <1> + + /** + * Return a worldly greeting message. + * + * @return {@link GreetingMessage} + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public GreetingMessage getDefaultMessage() { + return greetRestClient.get().getDefaultMessage(); // <2> + } + + /** + * Return a greeting message using the name that was provided. + * + * @param name the name to greet + * @return {@link GreetingMessage} + */ + @Path("/{name}") + @GET + @Produces(MediaType.APPLICATION_JSON) + public GreetingMessage getMessage(@PathParam("name") String name) { + return greetRestClient.get().getMessage(name); + } + + /** + * Set the greeting to use in future messages. + * + * @param message JSON containing the new greeting + * @return {@link jakarta.ws.rs.core.Response} + */ + @Path("/greeting") + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response updateGreeting(GreetingMessage message) { + return greetRestClient.get().updateGreeting(message); + } + + private static GreetRestClient prepareClient() { // <3> + Config config = ConfigProvider.getConfig(); + String serverHost = config.getOptionalValue("server.host", String.class).orElse("localhost"); + String serverPort = config.getOptionalValue("server.port", String.class).orElse("8080"); + return RestClientBuilder.newBuilder() + .baseUri(URI.create("http://" + serverHost + ":" + serverPort)) + .build(GreetRestClient.class); + } + } + // end::snippet_2[] + } + +} diff --git a/docs/src/main/java/io/helidon/docs/mp/RestclientSnippets.java b/docs/src/main/java/io/helidon/docs/mp/restclient/RestclientSnippets.java similarity index 100% rename from docs/src/main/java/io/helidon/docs/mp/RestclientSnippets.java rename to docs/src/main/java/io/helidon/docs/mp/restclient/RestclientSnippets.java diff --git a/microprofile/bundles/helidon-microprofile/pom.xml b/microprofile/bundles/helidon-microprofile/pom.xml index bd1ba22548e..396e9962cfe 100644 --- a/microprofile/bundles/helidon-microprofile/pom.xml +++ b/microprofile/bundles/helidon-microprofile/pom.xml @@ -70,6 +70,10 @@ io.helidon.microprofile.rest-client helidon-microprofile-rest-client + + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + org.glassfish.jersey.media jersey-media-json-binding diff --git a/microprofile/pom.xml b/microprofile/pom.xml index 5f157a3c6d8..b8ad67b5259 100644 --- a/microprofile/pom.xml +++ b/microprofile/pom.xml @@ -59,6 +59,7 @@ service-common telemetry testing + rest-client-metrics diff --git a/microprofile/rest-client-metrics/pom.xml b/microprofile/rest-client-metrics/pom.xml new file mode 100644 index 00000000000..c2bc205d2d5 --- /dev/null +++ b/microprofile/rest-client-metrics/pom.xml @@ -0,0 +1,131 @@ + + + + 4.0.0 + + io.helidon.microprofile + helidon-microprofile-project + 4.2.0-SNAPSHOT + + + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + + Helidon Microprofile REST Client Metrics + + + Support for MicroProfile Metrics in the REST client + + + + + org.eclipse.microprofile.metrics + microprofile-metrics-api + + + io.helidon.microprofile.metrics + helidon-microprofile-metrics + + + io.helidon.common + helidon-common-config + + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + jakarta.ws.rs + jakarta.ws.rs-api + provided + + + io.helidon.common + helidon-common-context + + + org.glassfish.jersey.core + jersey-common + + + io.helidon.common.features + helidon-common-features-api + true + + + io.helidon.microprofile.rest-client + helidon-microprofile-rest-client + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + io.helidon.microprofile.testing + helidon-microprofile-testing-junit5 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + + + diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsAutoDiscoverable.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsAutoDiscoverable.java new file mode 100644 index 00000000000..b9ec0eb31a7 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsAutoDiscoverable.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import jakarta.ws.rs.ConstrainedTo; +import jakarta.ws.rs.RuntimeType; +import jakarta.ws.rs.core.FeatureContext; +import org.glassfish.jersey.internal.spi.AutoDiscoverable; + +/** + * Autodiscoverable to register the filter. + */ +@ConstrainedTo(RuntimeType.SERVER) +public class RestClientMetricsAutoDiscoverable implements AutoDiscoverable { + + /** + * For service loading. + */ + @Deprecated + public RestClientMetricsAutoDiscoverable() { + } + + @Override + public void configure(FeatureContext featureContext) { + featureContext.register(RestClientMetricsFilter.class); + } +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java new file mode 100644 index 00000000000..12025f410fd --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.StringJoiner; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Initialized; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.spi.AfterBeanDiscovery; +import jakarta.enterprise.inject.spi.AnnotatedMethod; +import jakarta.enterprise.inject.spi.AnnotatedType; +import jakarta.enterprise.inject.spi.BeforeBeanDiscovery; +import jakarta.enterprise.inject.spi.Extension; +import jakarta.enterprise.inject.spi.ProcessAnnotatedType; +import jakarta.enterprise.inject.spi.WithAnnotations; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HEAD; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.client.ClientRequestContext; +import org.eclipse.microprofile.metrics.Counter; +import org.eclipse.microprofile.metrics.Metadata; +import org.eclipse.microprofile.metrics.Metric; +import org.eclipse.microprofile.metrics.MetricRegistry; +import org.eclipse.microprofile.metrics.Tag; +import org.eclipse.microprofile.metrics.Timer; +import org.eclipse.microprofile.metrics.annotation.Counted; +import org.eclipse.microprofile.metrics.annotation.Timed; + +import static java.lang.System.Logger.Level.DEBUG; + +/** + * CDI extension for REST client metrics support. + */ +public class RestClientMetricsCdiExtension implements Extension { + + private static final System.Logger LOGGER = System.getLogger(RestClientMetricsCdiExtension.class.getName()); + + private static final String SAVED_START_TIME_PROPERTY_NAME = RestClientMetricsFilter.class.getName() + ".startTime"; + + private static final List> REST_METHOD_ANNOTATIONS = List.of(OPTIONS.class, + HEAD.class, + GET.class, + POST.class, + PUT.class, + DELETE.class); + + private final Set> candidateRestClientTypes = new HashSet<>(); + + private final Map> metricsUpdateWorkByMethod = new HashMap<>(); + + private final Map, Map>> registrations = new HashMap<>(); + + private MetricRegistry metricRegistry; + + /** + * For service loading. + */ + public RestClientMetricsCdiExtension() { + } + + void checkForMpMetrics(@Observes BeforeBeanDiscovery bbd) { + + } + + void recordRestClientTypes(@Observes @WithAnnotations({OPTIONS.class, + HEAD.class, + GET.class, + POST.class, + PUT.class, + DELETE.class, + PATCH.class}) ProcessAnnotatedType pat) { + if (pat.getAnnotatedType().getJavaClass().isInterface()) { + /* + All REST client declarations are interfaces, so at least this annotated type has a chance of being a REST client. + At this stage in processing simply record all classes with at least one method bearing a REST annotation. + */ + Class javaType = pat.getAnnotatedType().getJavaClass(); + candidateRestClientTypes.add(javaType); + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + LOGGER.log(System.Logger.Level.TRACE, "Recording " + javaType.getCanonicalName() + " for REST client processing"); + } + } + } + + void prepareMetricRegistrations(@Observes AfterBeanDiscovery abd) { + // For each identified candidate REST client type determine what metric registration(s) are needed for its methods + // and prepare the pre-invoke and post-invoke operations as needed for each to update the metrics needed for each method. + // We do not actually register the metrics yet; instead we want until the REST client infrastructure informs us type + // by type that an interface is being used as a REST client interface. + + candidateRestClientTypes.forEach(type -> { + + LOGGER.log(DEBUG, "Analyzing candidate REST client interface " + type.getCanonicalName()); + + // Earlier we collected all interfaces with REST annotations. For each of those compute the type closure and, for the + // type itself and all types in its closure: + // * capture any type-level REST annotations - these will apply to all REST methods on the interface being processed + // * for each REST method on the interface or on a type in its type closure use the method-level REST annotations + // and any type-level REST annotations to prepare metric registrations. + + Set typeLevelMetricAnnotationsOverTypeClosure = + StreamSupport.stream(abd.getAnnotatedTypes(type).spliterator(), false) + .flatMap(at -> at.getTypeClosure() + .stream()) // The CDI-provided type closure includes the type itself. + .filter(t -> t instanceof Class) + .map(t -> (Class) t) + .filter(candidateRestClientTypes::contains) + .flatMap(t -> StreamSupport.stream(abd.getAnnotatedTypes(t).spliterator(), false)) + .flatMap(at -> Stream.of(Timed.class, Counted.class) + .map(at::getAnnotation) + .filter(Objects::nonNull)) + .collect(Collectors.toSet()); + + Map> registrationsByMethodForType = new HashMap<>(); + + // We need to get the AnnotatedType for the type of interest, but without knowing the ID with which it was added + // to CDI we cannot retrieve just that one directly. Instead retrieve all annotated types for the type (most likely + // there will be just one). + StreamSupport.stream(abd.getAnnotatedTypes(type).spliterator(), false) + .flatMap(at -> at.getTypeClosure().stream()) // The CDI-provided type closure includes the type itself. + .filter(t -> t instanceof Class) + .map(t -> (Class) t) + .filter(candidateRestClientTypes::contains) + .forEach(typeInClosure -> StreamSupport.stream(abd.getAnnotatedTypes(typeInClosure).spliterator(), + false) + .forEach(annotatedTypeInClosure -> { + LOGGER.log(DEBUG, + "Examining type " + annotatedTypeInClosure.getJavaClass() + .getCanonicalName()); + + annotatedTypeInClosure.getMethods().stream() + .filter(RestClientMetricsCdiExtension::hasRestAnnotation) + .forEach(annotatedMethod -> { + // Record registrations needed for this method based on + // annotations on it or the types in this type's closure. + Set registrationsForMethod = + registrationsByMethodForType.computeIfAbsent( + annotatedMethod.getJavaMember(), + k -> new HashSet<>()); + Set registrationsFromMethod = + Stream.of(Timed.class, + Counted.class) + .map(annotatedMethod::getAnnotation) + .filter(Objects::nonNull) + .map(anno -> Registration.create( + annotatedTypeInClosure.getJavaClass(), + annotatedMethod, + anno, + false)) + .collect(Collectors.toSet()); + + registrationsForMethod.addAll(registrationsFromMethod); + + LOGGER.log(DEBUG, + "Adding metric registrations for annotations " + + "on method " + annotatedMethod.getJavaMember() + .getDeclaringClass().getCanonicalName() + + "." + annotatedMethod.getJavaMember() + .getName() + ":" + registrationsFromMethod); + + // Record registrations needed for this method based on + // type-level annotations. + + var registrationsFromTypeLevelAnnotations = + typeLevelMetricAnnotationsOverTypeClosure.stream() + .map(anno -> Registration.create( + annotatedTypeInClosure.getJavaClass(), + annotatedMethod, + anno, + true)) + .collect(Collectors.toSet()); + + registrationsForMethod.addAll(registrationsFromTypeLevelAnnotations); + LOGGER.log(DEBUG, + "Adding metric registrations for type-level " + + "annotations " + registrationsFromTypeLevelAnnotations); + }); + registrations.put(type, registrationsByMethodForType); + } + )); + }); + } + + void ready(@Observes @Initialized(ApplicationScoped.class) Object event, + MetricRegistry metricRegistry) { + this.metricRegistry = metricRegistry; + } + + void registerMetricsForRestClient(Class restClient) { + registrations.get(restClient).forEach((method, regs) -> { + List metricsRegisteredForRestClient = LOGGER.isLoggable(DEBUG) ? new ArrayList<>() : null; + regs.forEach(registration -> { + Metric metric = registration.registrationOp.apply(metricRegistry); + if (LOGGER.isLoggable(DEBUG)) { + metricsRegisteredForRestClient.add(metric); + LOGGER.log(DEBUG, String.format("For REST client method %s#%s registering metric using %s", + restClient.getCanonicalName(), + method.getName(), + registration, + metric)); + } + metricsUpdateWorkByMethod.computeIfAbsent(method, k -> new ArrayList<>()) + .add(MetricsUpdateWork.create(metric)); + if (metricsRegisteredForRestClient != null && metricsRegisteredForRestClient.isEmpty()) { + LOGGER.log(DEBUG, "No metrics registered for REST client " + restClient.getCanonicalName()); + } + }); + }); + } + + void doPreWork(Method method, ClientRequestContext context) { + List workItems = metricsUpdateWorkByMethod.get(method); + if (workItems != null) { + workItems.forEach(workItem -> workItem.preWork(context)); + } + } + + void doPostWork(Method method, ClientRequestContext context) { + + List workItems = metricsUpdateWorkByMethod.get(method); + if (workItems != null) { + workItems.forEach(workItem -> workItem.postWork(context)); + } + } + + // For testing. + Map> metricsUpdateWorkByMethod() { + return metricsUpdateWorkByMethod; + } + + private static Tag[] tags(Annotation metricAnnotation) { + return switch (metricAnnotation) { + case Counted counted -> tags(counted.tags()); + case Timed timed -> tags(timed.tags()); + default -> null; + }; + } + + /** + * Converts tag expressions in a metrics annotation to an array of {@link org.eclipse.microprofile.metrics.Tag} for use + * during metric registration. + * + * @param tagExprs tag expressions (tag=value) from the metrics annotation + * @return tag array + */ + private static Tag[] tags(String[] tagExprs) { + return Stream.of(tagExprs) + .map(tagExpr -> { + int eq = tagExpr.indexOf("="); + if (eq <= 0 || eq == tagExpr.length() - 1) { + throw new IllegalArgumentException("Tag expression " + + tagExpr + + " in annotation has missing or misplaced = sign."); + } + return new Tag(tagExpr.substring(0, eq).trim(), tagExpr.substring(eq).trim()); + }) + .toArray(Tag[]::new); + } + + private static boolean hasRestAnnotation(AnnotatedMethod am) { + return am.getAnnotations().stream() + .anyMatch(anno -> REST_METHOD_ANNOTATIONS.contains(anno.annotationType())); + } + + private static String chooseMetricName(Class type, + AnnotatedMethod method, + Annotation metricAnnotation, + boolean isTypeLevel) { + boolean isAbsolute = switch (metricAnnotation) { + case Timed timed -> timed.absolute(); + case Counted counted -> counted.absolute(); + default -> false; + }; + String specifiedName = switch (metricAnnotation) { + case Timed timed -> timed.name(); + case Counted counted -> counted.name(); + default -> ""; + }; + AnnotatedType declaringType = method.getDeclaringType(); + + // The following code mimics the structure of the Annotated Naming Convention tables in the MP Metrics spec document. + return !isTypeLevel + ? // Annotation is at the method level. + isAbsolute + ? ( + specifiedName.isEmpty() + ? method.getJavaMember().getName() + : specifiedName) + : // Non-absolute name at the method level always has the canonical class name as its prefix. + declaringType.getJavaClass().getCanonicalName() + + "." + + ( + specifiedName.isEmpty() + ? method.getJavaMember().getName() + : specifiedName) + + : // Annotation is at the type level. Choose the prefix; the metric name always ends with the method name. + ( + isAbsolute + ? ( + specifiedName.isEmpty() ? type.getSimpleName() + : specifiedName) + : ( + specifiedName.isEmpty() ? declaringType.getJavaClass().getCanonicalName() + : declaringType.getJavaClass().getPackageName() + "." + specifiedName)) + + "." + method.getJavaMember().getName(); + } + + /** + * Metrics update work to be performed by a filter to update a metric, consisting of either of both of pre-work (metrics + * work to be done before the operation is performed) and post-work (metrics work to be done after the operation completes). + * + * @param preWork metrics work to do before the operation runs + * @param postWork metrics work to do after the operation runs + */ + record MetricsUpdateWork(Consumer preWork, Consumer postWork) { + + static MetricsUpdateWork create(Metric metric) { + return switch (metric) { + case Timer timer -> MetricsUpdateWork.create(cctx -> cctx.setProperty(SAVED_START_TIME_PROPERTY_NAME, + System.nanoTime()), + cctx -> { + long startTime = + (Long) cctx.getProperty(SAVED_START_TIME_PROPERTY_NAME); + timer.update(Duration.ofNanos(System.nanoTime() - startTime)); + }); + case Counter counter -> MetricsUpdateWork.create(cctx -> counter.inc()); + default -> null; + }; + } + + void preWork(ClientRequestContext requestContext) { + if (preWork != null) { + preWork.accept(requestContext); + } + } + + void postWork(ClientRequestContext requestContext) { + if (postWork != null) { + postWork.accept(requestContext); + } + } + + private static MetricsUpdateWork create(Consumer preWork, Consumer postWork) { + return new MetricsUpdateWork(preWork, postWork); + } + + private static MetricsUpdateWork create(Consumer preWork) { + return new MetricsUpdateWork(preWork, null); + } + } + + /** + * A future group of metric registrations to be performed if and when the corresponding REST client interface is reated. + * + * @param metricName metric name + * @param metricAnnotation metric annotation which gave rise to this registration + * @param registrationOp function to register the new metric in a metric registry + */ + private record Registration(String metricName, + Tag[] tags, + Metadata metadata, + Annotation metricAnnotation, + Function registrationOp) { + + static Registration create(Class declaringType, + AnnotatedMethod method, + Annotation metricAnnotation, + boolean isTypeLevel) { + Metadata metadata = Metadata.builder() + .withName(chooseMetricName(declaringType, method, metricAnnotation, isTypeLevel)) + .withDescription("REST client " + + ( + metricAnnotation.annotationType().isAssignableFrom(Timed.class) + ? "timer" + : "counter") + + declaringType.getSimpleName() + + "." + + method.getJavaMember().getName()) + .build(); + + Tag[] tagsFromAnnotation = RestClientMetricsCdiExtension.tags(metricAnnotation); + return switch (metricAnnotation) { + case Timed timed -> new Registration(metadata.getName(), + tagsFromAnnotation, + metadata, + timed, + mr -> mr.timer(metadata, tagsFromAnnotation)); + case Counted counted -> new Registration(metadata.getName(), + tagsFromAnnotation, + metadata, + counted, + mr -> mr.counter(metadata, tagsFromAnnotation)); + default -> null; + }; + } + + @Override + public String toString() { + return new StringJoiner(", ", Registration.class.getSimpleName() + "[", "]") + .add("metadata=" + metadata) + .add("tags=" + Arrays.toString(tags)) + .add("metricAnnotation=" + metricAnnotation) + .toString(); + } + } +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsClientListener.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsClientListener.java new file mode 100644 index 00000000000..ebb04a9582f --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsClientListener.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import java.util.HashSet; +import java.util.Set; + +import io.helidon.common.LazyValue; + +import jakarta.enterprise.inject.spi.CDI; +import jakarta.ws.rs.Priorities; +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.eclipse.microprofile.rest.client.spi.RestClientListener; + +/** + * REST client metrics listener to add our filter for outbound REST clients. + */ +public class RestClientMetricsClientListener implements RestClientListener { + + /* + The listener can be instantiated multiple times, so we delegate the real work to a singleton. + */ + private static final LazyValue LISTENER = LazyValue.create(Listener::new); + + /** + * For service discovery. + */ + public RestClientMetricsClientListener() { + } + + @Override + public void onNewClient(Class serviceInterface, RestClientBuilder builder) { + LISTENER.get().onNewClient(serviceInterface, builder); + } + + private static class Listener { + + private final RestClientMetricsFilter restClientMetricsFilter; + + private final LazyValue restClientMetricsConfig = + LazyValue.create(() -> { + boolean enabled = ConfigProvider.getConfig() + .getOptionalValue(RestClientMetricsFilter.REST_CLIENT_METRICS_CONFIG_KEY + + ".enabled", Boolean.class) + .orElse(true); + return RestClientMetricsConfig.builder() + .enabled(enabled) + .build(); + }); + + private final LazyValue ext = + LazyValue.create(() -> CDI.current().getBeanManager().getExtension(RestClientMetricsCdiExtension.class)); + + private final Set> restClientsDiscovered = new HashSet<>(); + + private Listener() { + restClientMetricsFilter = RestClientMetricsFilter.create(); + } + + private void onNewClient(Class serviceInterface, RestClientBuilder builder) { + if (restClientMetricsConfig.get().enabled()) { + // Users might build multiple REST client builders (and instances) for a given interface, but we + // register metrics (and create metric-related work for the filter to do) only upon first + // discovering a given service interface. + if (restClientsDiscovered.add(serviceInterface)) { + ext.get().registerMetricsForRestClient(serviceInterface); + } + builder.register(restClientMetricsFilter, Priorities.USER - 100); + } + } + } +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsConfigBlueprint.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsConfigBlueprint.java new file mode 100644 index 00000000000..3991d9b4bf6 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsConfigBlueprint.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * Configuration settings for MP REST client metrics. + */ +@Prototype.Blueprint +@Prototype.Configured(RestClientMetricsConfigBlueprint.CONFIG_KEY) +interface RestClientMetricsConfigBlueprint { + + /** + * Root=level config key for REST client metrics settings. + */ + String CONFIG_KEY = "rest-client.metrics"; + + /** + * Whether REST client metrics functionality is enabled. + * + * @return if REST client metrics are configured to be enabled + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean enabled(); +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsFilter.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsFilter.java new file mode 100644 index 00000000000..451f6455b36 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsFilter.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import java.lang.reflect.Method; + +import jakarta.annotation.Priority; +import jakarta.enterprise.inject.spi.CDI; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.client.ClientResponseContext; +import jakarta.ws.rs.client.ClientResponseFilter; +import jakarta.ws.rs.ext.Provider; + +/** + * Filter which automatically registers and updates metrics for outgoing REST client requests. + *

+ * An instance of this filter is added explicitly to the filter chain for each REST client interface + *

+ */ +@Priority(Priorities.USER - 100) +@Provider +class RestClientMetricsFilter implements ClientRequestFilter, ClientResponseFilter { + + static final String REST_CLIENT_METRICS_CONFIG_KEY = "rest-client.metrics"; + + private static final String INVOKED_METHOD = "org.eclipse.microprofile.rest.client.invokedMethod"; + + private final RestClientMetricsCdiExtension ext; + + private RestClientMetricsFilter() { + ext = CDI.current().getBeanManager().getExtension(RestClientMetricsCdiExtension.class); + } + + static RestClientMetricsFilter create() { + return new RestClientMetricsFilter(); + } + + @Override + public void filter(ClientRequestContext requestContext) { + Method javaMethod = (Method) requestContext.getProperty(INVOKED_METHOD); + if (javaMethod != null) { + ext.doPreWork(javaMethod, requestContext); + } + } + + @Override + public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) { + Method javaMethod = (Method) requestContext.getProperty(INVOKED_METHOD); + if (javaMethod != null) { + ext.doPostWork(javaMethod, requestContext); + } + } +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/package-info.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/package-info.java new file mode 100644 index 00000000000..1e6f33056d4 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Metrics support for MP REST Client. + */ +package io.helidon.microprofile.restclientmetrics; diff --git a/microprofile/rest-client-metrics/src/main/java/module-info.java b/microprofile/rest-client-metrics/src/main/java/module-info.java new file mode 100644 index 00000000000..0d22d561959 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/module-info.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import io.helidon.common.features.api.Feature; +import io.helidon.common.features.api.HelidonFlavor; + +/** + * MP Rest client metrics. + * + * @see org.eclipse.microprofile.rest.client + */ +@Feature(value = "REST Client Metrics", + description = "MicroProfile REST client spec implementation", + in = HelidonFlavor.MP, + path = "REST Client Metrics" +) +@SuppressWarnings({"requires-automatic", "requires-transitive-automatic"}) +module io.helidon.microprofile.restclient.metrics { + + requires io.helidon.microprofile.metrics; + + requires transitive jakarta.ws.rs; + requires jakarta.inject; + requires transitive jersey.common; + requires microprofile.metrics.api; + requires jakarta.cdi; + requires io.helidon.metrics.api; + requires microprofile.rest.client.api; + requires io.helidon.webserver; + requires java.xml; + + requires static io.helidon.common.features.api; + + exports io.helidon.microprofile.restclientmetrics; + + opens io.helidon.microprofile.restclientmetrics to weld.core.impl; + + provides jakarta.enterprise.inject.spi.Extension + with io.helidon.microprofile.restclientmetrics.RestClientMetricsCdiExtension; + + provides org.glassfish.jersey.internal.spi.AutoDiscoverable + with io.helidon.microprofile.restclientmetrics.RestClientMetricsAutoDiscoverable; + + provides org.eclipse.microprofile.rest.client.spi.RestClientListener + with io.helidon.microprofile.restclientmetrics.RestClientMetricsClientListener; +} \ No newline at end of file diff --git a/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/MetricIdMatcher.java b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/MetricIdMatcher.java new file mode 100644 index 00000000000..0975ac69087 --- /dev/null +++ b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/MetricIdMatcher.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import org.eclipse.microprofile.metrics.MetricID; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +class MetricIdMatcher { + + static WithName withName(Matcher matcher) { + return new WithName(matcher); + } + + private static class WithName extends TypeSafeMatcher { + + private final Matcher matcher; + + private WithName(Matcher matcher) { + this.matcher = matcher; + } + + @Override + protected boolean matchesSafely(MetricID item) { + return matcher.matches(item.getName()); + } + + @Override + public void describeTo(Description description) { + description.appendText("metric ID name"); + description.appendDescriptionOf(matcher); + } + } +} diff --git a/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestClient.java b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestClient.java new file mode 100644 index 00000000000..84a932c60d7 --- /dev/null +++ b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestClient.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HEAD; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; + +public interface TestClient { + + @GET + @Path("/get") + String get(); + + @PUT + @Path("put") + String put(String message); + + @HEAD + @Path("/unannotatedRestMethod") + void unmeasuredRestMethod(); +} diff --git a/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestScanning.java b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestScanning.java new file mode 100644 index 00000000000..3869c4885a7 --- /dev/null +++ b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestScanning.java @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.helidon.microprofile.testing.junit5.AddBean; +import io.helidon.microprofile.testing.junit5.HelidonTest; + +import jakarta.enterprise.inject.spi.CDI; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HEAD; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.client.WebTarget; +import org.eclipse.microprofile.metrics.Counter; +import org.eclipse.microprofile.metrics.MetricID; +import org.eclipse.microprofile.metrics.MetricRegistry; +import org.eclipse.microprofile.metrics.Timer; +import org.eclipse.microprofile.metrics.annotation.Counted; +import org.eclipse.microprofile.metrics.annotation.Timed; +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +@HelidonTest +@AddBean(TestScanning.ServiceClient.class) +@AddBean(TestScanning.ServiceClientParent.class) +@AddBean(TestService.class) +class TestScanning { + + @Inject + WebTarget webTarget; + + private ServiceClient serviceClient; + + @BeforeEach + void init() { + serviceClient = RestClientBuilder.newBuilder() + .baseUri(webTarget.getUri()) + .build(ServiceClient.class); + } + + @Test + void annotationsOnMethods() throws NoSuchMethodException { + RestClientMetricsCdiExtension extension = CDI.current().getBeanManager() + .getExtension(RestClientMetricsCdiExtension.class); + Map> filterWorkByMethod = + extension.metricsUpdateWorkByMethod(); + assertThat("Check for expected filter work", filterWorkByMethod.keySet(), + allOf(hasItems(equalTo(ServiceClient.class.getMethod("get")), + equalTo(ServiceClient.class.getMethod("put"))), + not(hasItems(equalTo(ServiceClient.class.getMethod("timedNonRestMethod")), + equalTo(ServiceClient.class.getMethod("countedNonRestMethod")), + equalTo(ServiceClient.class.getMethod("unannotatedRestMethod")))))); + } + + @Test + void checkMetricsRegistrationsFromMethods() { + MetricRegistry metricRegistry = CDI.current().select(MetricRegistry.class).get(); + + // get method + + // relative, automatic name = declaring-class.element-name + Timer getTimer = metricRegistry.getTimer(new MetricID(ServiceClient.class.getCanonicalName() + ".get")); + assertThat("Relative automatically-named timer for get", getTimer, notNullValue()); + + // absolute, explicit name = specified-name + Counter getCounter = metricRegistry.getCounter(new MetricID("getAbs")); + assertThat("Absolute explicitly-named counter for get", getCounter, notNullValue()); + + // get2 method + + // absolute, automatic name = element-name + Timer get2Timer = metricRegistry.getTimer(new MetricID("get2")); + assertThat("Absolute automatically-named timer for get2", get2Timer, notNullValue()); + + // relative, explicit name = declaring-class.specified-name + Counter get2Counter = metricRegistry.getCounter(new MetricID(ServiceClient.class.getCanonicalName() + ".relget2")); + assertThat("Relative explicitly-named counter for get2", get2Counter, notNullValue()); + + // timedNonRestMethod + Timer timedNonRestMethodTimer = metricRegistry.getTimer(new MetricID(ServiceClient.class.getCanonicalName() + + ".timedNonRestMethod")); + assertThat("Relative automatically-named timer for non-REST method", timedNonRestMethodTimer, nullValue()); + + // put method + + // relative, automatic name = declaring-class.element-name + Counter putCounter = metricRegistry.getCounter(new MetricID(ServiceClient.class.getCanonicalName() + ".put")); + assertThat("Relative automatically-named counter for put", putCounter, notNullValue()); + + // absolute, explicit name = specified-name + Timer putTimer = metricRegistry.getTimer(new MetricID("putAbs")); + assertThat("Absolute explicitly-named timer for put", putTimer, notNullValue()); + + // countedNonRestMethod + Counter nonRestMethodCounter = metricRegistry.getCounter(new MetricID("shouldNotAppear")); + assertThat("Counter for non-REST method", nonRestMethodCounter, nullValue()); + + // non-REST and unmeasured + var metrics = metricRegistry.getMetrics(); + assertThat("Metrics that should not appear", + metrics.keySet(), + allOf(not(contains("shouldNotAppear")), + not(contains("countedNonRestMethod")))); + } + + @Test + void checkMetricsRegistrationsFromType() { + MetricRegistry metricRegistry = CDI.current().select(MetricRegistry.class).get(); + + // get method + + // type-level absolute with explicit name = specified-name.method-name + Timer getTimerFromType = metricRegistry.getTimer(new MetricID("typeLevelAbs.get")); + assertThat("Type-level timer with absolute explicit name", getTimerFromType, notNullValue()); + + // type-level relative with explicit name = package.specified-name.method-name + Counter getCounterFromType = metricRegistry.getCounter(new MetricID(ServiceClient.class.getPackageName() + + ".typeLevelRel.get")); + assertThat("Type-level counter with relative explicit name", getCounterFromType, notNullValue()); + + Counter unmeasuredGetCounterFromType = metricRegistry.getCounter(new MetricID(ServiceClient.class.getPackageName() + + ".typeLevelRel" + + ".unannotatedRestMethod")); + assertThat("unmeasuredRest counter from type-level annotation", unmeasuredGetCounterFromType, notNullValue()); + } + + @Test + void checkMetricsUpdates() { + + MetricRegistry metricRegistry = CDI.current().select(MetricRegistry.class).get(); + + List timers = new ArrayList<>(); + + // All on the get method. + + // relative automatically-named timer on the get method in the subinterface = subtype.method-name + TimerInfo getTimerInfo = TimerInfo.create(metricRegistry, ServiceClient.class.getCanonicalName() + ".get"); + assertThat("Relative automatically-named subinterface method-level timer for get method", getTimerInfo, notNullValue()); + Duration elapsedTimeBefore = getTimerInfo.timer.getElapsedTime(); + + timers.add(getTimerInfo); + + // absolute explicitly-named timer on the subinterface = specified-value.method-name + + TimerInfo getTimerFromTypeInfo = TimerInfo.create(metricRegistry, "typeLevelAbs.get"); + + assertThat("Absolute explicitly-named timer from the subinterface", getTimerFromTypeInfo.timer, notNullValue()); + + timers.add(getTimerFromTypeInfo); + + // relative automatically-named timer on parentGet method on superinterface = subtype.method-name + TimerInfo getTimerFromSuperTypeInfo = TimerInfo.create(metricRegistry, ServiceClient.class.getCanonicalName() + + ".get"); + assertThat("Relative automatically-named timer on method in superinterface", getTimerFromSuperTypeInfo.timer, + notNullValue()); + + timers.add(getTimerFromSuperTypeInfo); + + // relatively explicitly-named timer on superinterface = subtype-package.specified-name.method-name + TimerInfo inheritedGetMethodTimerInfo = TimerInfo.create(metricRegistry, ServiceClient.class.getPackageName() + + ".parentLevelRel.get"); + assertThat("Inherited relative auto-named type-level timer for get method", + inheritedGetMethodTimerInfo.timer, + notNullValue()); + + timers.add(inheritedGetMethodTimerInfo); + + String timedGetResult = serviceClient.get(); + + assertThat("Timed get result", timedGetResult, equalTo("get")); + Duration elapsedTimeAfter = getTimerInfo.timer.getElapsedTime(); + assertThat("Timer delta", elapsedTimeAfter.compareTo(elapsedTimeBefore), greaterThan(0)); + + String parentGetResult = serviceClient.parentGet(); + assertThat("Parent get result", parentGetResult, equalTo("parent get")); + + for (TimerInfo timerInfo : timers) { + assertThat("Timer for timer info " + timerInfo.metricId, timerInfo.timer, notNullValue()); + assertThat("Counter update for " + timerInfo.metricId, + timerInfo.timer.getCount(), + greaterThan(timerInfo.beforeCount)); + } + } + + @Test + void checkInheritance() { + MetricRegistry metricRegistry = CDI.current().select(MetricRegistry.class).get(); + + // parentGet method + + // method-level relative automatic name = declaring-class.method-name (declaring class is the superinterface) + Timer parentGetMethodTimer = metricRegistry.getTimer(new MetricID(ServiceClientParent.class.getCanonicalName() + + ".parentGet")); + assertThat("Relative automatically-named timer for inherited parentGet method", parentGetMethodTimer, notNullValue()); + + // get method + + // method-level absolute explicit name = specified-name + Counter getMethodCounterFromParent = metricRegistry.getCounter(new MetricID("parentGetAbs")); + assertThat("Absolute explicitly-named counter for inherited get method", getMethodCounterFromParent, notNullValue()); + + // type-level relative explicit name = package-of-declaring-class.specified-name.method-name + Timer superTypeLevelTimerForGet = metricRegistry.getTimer(new MetricID(ServiceClientParent.class.getPackageName() + + ".parentLevelRel.get")); + assertThat("Type-level relative explicitly-named counter for inherited get method", + superTypeLevelTimerForGet, + notNullValue()); + + // put method + + // type-level absolute explicit name = specified-name.method-name + Counter inheritedPutMethodCounter = metricRegistry.getCounter(new MetricID("parentLevelAbs.put")); + assertThat("Type-level absolute explicitly-named counter inherited for put method", + inheritedPutMethodCounter, + notNullValue()); + } + + @Timed(name = "parentLevelRel") + @Counted(name = "parentLevelAbs", absolute = true) + interface ServiceClientParent { + + @Timed + @GET + @Path("/parentGet") + String parentGet(); + + @Counted(name = "parentGetAbs", absolute = true) + @GET + @Path("/get") + String get(); + + } + + @Path(TestService.RESOURCE_PATH) + @Counted(name = "typeLevelRel") + @Timed(name = "typeLevelAbs", absolute = true) + interface ServiceClient extends ServiceClientParent { + + @Timed + @Counted(absolute = true, name = "getAbs") + @GET + @Path("/get") + String get(); + + @Timed(absolute = true) + @Counted(name = "relget2") + @GET + @Path("/get2") + String get2(); + + @Timed + void timedNonRestMethod(); + + @Counted + @Timed(absolute = true, name = "putAbs") + @PUT + @Path("/put") + void put(); + + @Counted(name = "shouldNotAppear", absolute = true) + void countedNonRestMethod(); + + @HEAD + @Path("/unannotatedRestMethod") + void unannotatedRestMethod(); + } + + private record TimerInfo(Timer timer, MetricID metricId, long beforeCount) { + + static TimerInfo create(MetricRegistry metricRegistry, String metricName) { + MetricID metricID = new MetricID(metricName); + Timer timer = metricRegistry.getTimer(metricID); + return new TimerInfo(timer, metricID, timer != null ? timer.getCount() : 0L); + } + } + +} diff --git a/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestService.java b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestService.java new file mode 100644 index 00000000000..2ed8ad4b690 --- /dev/null +++ b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestService.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.restclientmetrics; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HEAD; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; + +@Path(TestService.RESOURCE_PATH) +public class TestService { + + static final String RESOURCE_PATH = "/restClientMetricsTest"; + + @GET + @Path("/get") + public String get() { + return "get"; + } + + @PUT + @Path("/put") + public String put(String message) { + return "I got " + message; + } + + @HEAD + @Path("/unannotatedRestMethod") + public void unmeasuredRestMethod() { + + } + + @GET + @Path("/parentGet") + public String parentGet() { + return "parent get"; + } + +}