diff --git a/build.gradle b/build.gradle index f0358803ef..018f2b5be4 100644 --- a/build.gradle +++ b/build.gradle @@ -337,8 +337,7 @@ subprojects { check.dependsOn("testModules") - if (!(project.name in ['micrometer-jakarta9', 'micrometer-java11', 'micrometer-jetty12'])) { - // add projects here that do not exist in the previous minor so should be excluded from japicmp + if (!(project.name in ['micrometer-jakarta9', 'micrometer-java11', 'micrometer-jetty12'])) { // add projects here that do not exist in the previous minor so should be excluded from japicmp apply plugin: 'me.champeau.gradle.japicmp' apply plugin: 'de.undercouch.download' diff --git a/micrometer-jetty12/build.gradle b/micrometer-jetty12/build.gradle index c4031d3cfa..b384695498 100644 --- a/micrometer-jetty12/build.gradle +++ b/micrometer-jetty12/build.gradle @@ -7,8 +7,9 @@ if (!javaLanguageVersion.canCompileOrRun(17)) { dependencies { api project(":micrometer-core") - optionalApi libs.jetty12Server + optionalApi libs.jetty12Server + optionalApi libs.jetty12Client testRuntimeOnly(libs.logback14) { version { @@ -16,8 +17,11 @@ dependencies { } } - testImplementation 'org.junit.jupiter:junit-jupiter' - testImplementation 'org.assertj:assertj-core' + testImplementation project(":micrometer-observation-test") + testImplementation project(":micrometer-test") + testImplementation libs.junitJupiter + testImplementation libs.assertj + testImplementation libs.wiremock } java { diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/DefaultJettyClientObservationConvention.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/DefaultJettyClientObservationConvention.java new file mode 100644 index 0000000000..c8bb5c6983 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/DefaultJettyClientObservationConvention.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.common.KeyValues; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Result; + +/** + * Default implementation of {@link JettyClientObservationConvention}. + * + * @since 1.13.0 + */ +public class DefaultJettyClientObservationConvention implements JettyClientObservationConvention { + + public static DefaultJettyClientObservationConvention INSTANCE = new DefaultJettyClientObservationConvention(); + + @Override + public KeyValues getLowCardinalityKeyValues(JettyClientContext context) { + Request request = context.getCarrier(); + Result result = context.getResponse(); + return KeyValues.of(JettyClientKeyValues.method(request), JettyClientKeyValues.host(request), + JettyClientKeyValues.uri(request, result, context.getUriPatternFunction()), + JettyClientKeyValues.exception(result), JettyClientKeyValues.status(result), + JettyClientKeyValues.outcome(result)); + } + + @Override + public String getName() { + return JettyClientMetrics.DEFAULT_JETTY_CLIENT_REQUESTS_TIMER_NAME; + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientContext.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientContext.java new file mode 100644 index 0000000000..9d760d859f --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientContext.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.observation.transport.RequestReplySenderContext; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Result; + +import java.util.Objects; +import java.util.function.BiFunction; + +/** + * Context to use when instrumenting Jetty client metrics with the Observation API. + * + * @since 1.13.0 + * @see JettyClientMetrics + */ +public class JettyClientContext extends RequestReplySenderContext { + + private final BiFunction uriPatternFunction; + + public JettyClientContext(Request request, BiFunction uriPatternFunction) { + super((carrier, key, value) -> Objects.requireNonNull(carrier) + .headers(httpFields -> httpFields.add(key, value))); + this.uriPatternFunction = uriPatternFunction; + setCarrier(request); + } + + public BiFunction getUriPatternFunction() { + return uriPatternFunction; + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientKeyValues.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientKeyValues.java new file mode 100644 index 0000000000..fcd6d10e17 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientKeyValues.java @@ -0,0 +1,163 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.lang.Nullable; +import io.micrometer.common.util.StringUtils; +import io.micrometer.core.instrument.binder.http.Outcome; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Result; +import org.eclipse.jetty.http.HttpStatus; + +import java.util.function.BiFunction; +import java.util.regex.Pattern; + +/** + * Factory methods for {@link KeyValue} associated with a request-response exchange that + * is handled by Jetty {@link org.eclipse.jetty.client.HttpClient}. + * + * @author Jon Schneider + * @since 1.13.0 + */ +public final class JettyClientKeyValues { + + private static final KeyValue URI_NOT_FOUND = KeyValue.of("uri", "NOT_FOUND"); + + private static final KeyValue URI_REDIRECTION = KeyValue.of("uri", "REDIRECTION"); + + private static final KeyValue URI_ROOT = KeyValue.of("uri", "root"); + + private static final KeyValue EXCEPTION_NONE = KeyValue.of("exception", "None"); + + private static final KeyValue EXCEPTION_UNKNOWN = KeyValue.of("exception", "UNKNOWN"); + + private static final KeyValue METHOD_UNKNOWN = KeyValue.of("method", "UNKNOWN"); + + private static final KeyValue HOST_UNKNOWN = KeyValue.of("host", "UNKNOWN"); + + private static final KeyValue STATUS_UNKNOWN = KeyValue.of("status", "UNKNOWN"); + + private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/$"); + + private static final Pattern MULTIPLE_SLASH_PATTERN = Pattern.compile("//+"); + + private static final KeyValue OUTCOME_UNKNOWN = KeyValue.of("outcome", "UNKNOWN"); + + private JettyClientKeyValues() { + } + + /** + * Creates a {@code method} KeyValue based on the {@link Request#getMethod() method} + * of the given {@code request}. + * @param request the request + * @return the method KeyValue whose value is a capitalized method (e.g. GET). + */ + public static KeyValue method(Request request) { + return (request != null) ? KeyValue.of("method", request.getMethod()) : METHOD_UNKNOWN; + } + + /** + * Creates a {@code host} KeyValue based on the {@link Request#getHost()} of the given + * {@code request}. + * @param request the request + * @return the host KeyValue derived from request + */ + public static KeyValue host(Request request) { + return (request != null) ? KeyValue.of("host", request.getHost()) : HOST_UNKNOWN; + } + + /** + * Creates a {@code status} KeyValue based on the status of the given {@code result}. + * @param result the request result + * @return the status KeyValue derived from the status of the response + */ + public static KeyValue status(@Nullable Result result) { + return result != null ? KeyValue.of("status", Integer.toString(result.getResponse().getStatus())) + : STATUS_UNKNOWN; + } + + /** + * Creates a {@code uri} KeyValue based on the URI of the given {@code result}. + * {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} for 404 responses. + * @param request the request + * @param result the request result + * @param successfulUriPattern successful URI pattern + * @return the uri KeyValue derived from the request and its result + */ + public static KeyValue uri(Request request, @Nullable Result result, + BiFunction successfulUriPattern) { + if (result != null && result.getResponse() != null) { + int status = result.getResponse().getStatus(); + if (HttpStatus.isRedirection(status)) { + return URI_REDIRECTION; + } + if (status == 404) { + return URI_NOT_FOUND; + } + } + + String matchingPattern = successfulUriPattern.apply(request, result); + matchingPattern = MULTIPLE_SLASH_PATTERN.matcher(matchingPattern).replaceAll("/"); + if (matchingPattern.equals("/")) { + return URI_ROOT; + } + matchingPattern = TRAILING_SLASH_PATTERN.matcher(matchingPattern).replaceAll(""); + return KeyValue.of("uri", matchingPattern); + } + + /** + * Creates an {@code exception} KeyValue based on the {@link Class#getSimpleName() + * simple name} of the class of the given {@code exception}. + * @param result the request result + * @return the exception KeyValue derived from the exception + */ + public static KeyValue exception(@Nullable Result result) { + if (result == null) { + return EXCEPTION_UNKNOWN; + } + Throwable exception = result.getFailure(); + if (exception == null) { + return EXCEPTION_NONE; + } + if (result.getResponse() != null) { + int status = result.getResponse().getStatus(); + if (status == 404 || HttpStatus.isRedirection(status)) { + return EXCEPTION_NONE; + } + } + if (exception.getCause() != null) { + exception = exception.getCause(); + } + String simpleName = exception.getClass().getSimpleName(); + return KeyValue.of("exception", + StringUtils.isNotEmpty(simpleName) ? simpleName : exception.getClass().getName()); + } + + /** + * Creates an {@code outcome} KeyValue based on the status of the given + * {@code result}. + * @param result the request result + * @return the outcome KeyValue derived from the status of the response + */ + public static KeyValue outcome(@Nullable Result result) { + if (result == null) { + return OUTCOME_UNKNOWN; + } + return Outcome.forStatus(result.getResponse().getStatus()).asKeyValue(); + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientMetrics.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientMetrics.java new file mode 100644 index 0000000000..0bff3bd384 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientMetrics.java @@ -0,0 +1,204 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.common.lang.Nullable; +import io.micrometer.core.annotation.Incubating; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.internal.OnlyOnceLoggingDenyMeterFilter; +import io.micrometer.core.instrument.observation.ObservationOrTimerCompatibleInstrumentation; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Result; +import org.eclipse.jetty.io.Content; + +import java.util.Optional; +import java.util.function.BiFunction; + +/** + * Provides request metrics for Jetty {@link org.eclipse.jetty.client.HttpClient}, + * configured as a {@link org.eclipse.jetty.client.Request.Listener Request.Listener}. + * Incubating in case there emerges a better way to handle path variable detection. + * + * @author Jon Schneider + * @since 1.13.0 + */ +@Incubating(since = "1.13.0") +public class JettyClientMetrics implements Request.Listener { + + static final String DEFAULT_JETTY_CLIENT_REQUESTS_TIMER_NAME = "jetty.client.requests"; + + private final MeterRegistry registry; + + private final JettyClientTagsProvider tagsProvider; + + private final String timingMetricName; + + private final String contentSizeMetricName; + + private final ObservationRegistry observationRegistry; + + @Nullable + private final JettyClientObservationConvention convention; + + private final BiFunction uriPatternFunction; + + private JettyClientMetrics(MeterRegistry registry, ObservationRegistry observationRegistry, + @Nullable JettyClientObservationConvention convention, JettyClientTagsProvider tagsProvider, + String timingMetricName, String contentSizeMetricName, int maxUriTags, + BiFunction uriPatternFunction) { + this.registry = registry; + this.tagsProvider = tagsProvider; + this.timingMetricName = timingMetricName; + this.contentSizeMetricName = contentSizeMetricName; + this.observationRegistry = observationRegistry; + this.convention = convention; + this.uriPatternFunction = uriPatternFunction; + + MeterFilter timingMetricDenyFilter = new OnlyOnceLoggingDenyMeterFilter( + () -> String.format("Reached the maximum number of URI tags for '%s'.", timingMetricName)); + MeterFilter contentSizeMetricDenyFilter = new OnlyOnceLoggingDenyMeterFilter( + () -> String.format("Reached the maximum number of URI tags for '%s'.", contentSizeMetricName)); + registry.config() + .meterFilter( + MeterFilter.maximumAllowableTags(this.timingMetricName, "uri", maxUriTags, timingMetricDenyFilter)) + .meterFilter(MeterFilter.maximumAllowableTags(this.contentSizeMetricName, "uri", maxUriTags, + contentSizeMetricDenyFilter)); + } + + @Override + public void onQueued(Request request) { + ObservationOrTimerCompatibleInstrumentation sample = ObservationOrTimerCompatibleInstrumentation + .start(registry, observationRegistry, () -> new JettyClientContext(request, uriPatternFunction), convention, + DefaultJettyClientObservationConvention.INSTANCE); + + request.onComplete(result -> { + sample.setResponse(result); + long requestLength = Optional.ofNullable(result.getRequest().getBody()) + .map(Content.Source::getLength) + .orElse(0L); + Iterable httpRequestTags = tagsProvider.httpRequestTags(result); + if (requestLength >= 0) { + DistributionSummary.builder(contentSizeMetricName) + .description("Content sizes for Jetty HTTP client requests") + .tags(httpRequestTags) + .register(registry) + .record(requestLength); + } + + sample.stop(timingMetricName, "Jetty HTTP client request timing", () -> httpRequestTags); + }); + } + + /** + * Create a builder for {@link JettyClientMetrics}. + * @param registry meter registry to use + * @param uriPatternFunction how to extract the URI pattern for tagging + * @return builder + */ + public static Builder builder(MeterRegistry registry, BiFunction uriPatternFunction) { + return new Builder(registry, uriPatternFunction); + } + + public static class Builder { + + private final MeterRegistry meterRegistry; + + private final BiFunction uriPatternFunction; + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + private JettyClientTagsProvider tagsProvider; + + private String timingMetricName = DEFAULT_JETTY_CLIENT_REQUESTS_TIMER_NAME; + + private String contentSizeMetricName = "jetty.client.request.size"; + + private int maxUriTags = 1000; + + @Nullable + private JettyClientObservationConvention observationConvention; + + private Builder(MeterRegistry registry, BiFunction uriPatternFunction) { + this.meterRegistry = registry; + this.uriPatternFunction = uriPatternFunction; + this.tagsProvider = result -> uriPatternFunction.apply(result.getRequest(), result); + } + + public Builder timingMetricName(String metricName) { + this.timingMetricName = metricName; + return this; + } + + public Builder contentSizeMetricName(String metricName) { + this.contentSizeMetricName = metricName; + return this; + } + + public Builder maxUriTags(int maxUriTags) { + this.maxUriTags = maxUriTags; + return this; + } + + /** + * Note that the {@link JettyClientTagsProvider} will not be used with + * {@link Observation} instrumentation when + * {@link #observationRegistry(ObservationRegistry)} is configured. + * @param tagsProvider tags provider to use with metrics instrumentation + * @return this builder + */ + public Builder tagsProvider(JettyClientTagsProvider tagsProvider) { + this.tagsProvider = tagsProvider; + return this; + } + + /** + * Configure an observation registry to instrument using the {@link Observation} + * API instead of directly with a {@link Timer}. + * @param observationRegistry registry with which to instrument + * @return this builder + */ + public Builder observationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + /** + * Provide a custom convention to override the default convention used when + * instrumenting with the {@link Observation} API. This only takes effect when a + * {@link #observationRegistry(ObservationRegistry)} is configured. + * @param convention semantic convention to use + * @return This builder instance. + * @see #observationRegistry(ObservationRegistry) + */ + public Builder observationConvention(JettyClientObservationConvention convention) { + this.observationConvention = convention; + return this; + } + + public JettyClientMetrics build() { + return new JettyClientMetrics(meterRegistry, observationRegistry, observationConvention, tagsProvider, + timingMetricName, contentSizeMetricName, maxUriTags, uriPatternFunction); + } + + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientObservationConvention.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientObservationConvention.java new file mode 100644 index 0000000000..317bbfc688 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientObservationConvention.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * Convention used with Jetty client instrumentation {@link JettyClientMetrics}. + * + * @since 1.13.0 + */ +public interface JettyClientObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof JettyClientContext; + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientObservationDocumentation.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientObservationDocumentation.java new file mode 100644 index 0000000000..b40756fc82 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientObservationDocumentation.java @@ -0,0 +1,107 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * {@link ObservationDocumentation} for the Jetty HTTP client. + * + * @since 1.13.0 + * @see JettyClientMetrics + */ +public enum JettyClientObservationDocumentation implements ObservationDocumentation { + + /** + * Default instrumentation from {@link JettyClientMetrics}. + */ + DEFAULT { + @Override + public Class> getDefaultConvention() { + return JettyClientObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return JettyClientLowCardinalityTags.values(); + } + }; + + enum JettyClientLowCardinalityTags implements KeyName { + + /** + * URI of the request. Ideally it should be the templated URI pattern to maintain + * low cardinality and support useful aggregation. + */ + URI { + @Override + public String asString() { + return "uri"; + } + }, + /** + * Exception thrown, if any. + */ + EXCEPTION { + @Override + public String asString() { + return "exception"; + } + }, + /** + * HTTP method of the request, if available. + */ + METHOD { + @Override + public String asString() { + return "method"; + } + }, + /** + * Description of the outcome of an HTTP request based on the HTTP status code + * category, if known. + */ + OUTCOME { + @Override + public String asString() { + return "outcome"; + } + }, + /** + * HTTP status of the response, if available. + */ + STATUS { + @Override + public String asString() { + return "status"; + } + }, + /** + * Host used in the request. + */ + HOST { + @Override + public String asString() { + return "host"; + } + } + + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientTags.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientTags.java new file mode 100644 index 0000000000..c62a1b1e13 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientTags.java @@ -0,0 +1,148 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.common.util.StringUtils; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.binder.http.Outcome; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Response; +import org.eclipse.jetty.client.Result; +import org.eclipse.jetty.http.HttpStatus; + +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * Factory methods for {@link Tag Tags} associated with a request-response exchange that + * is handled by Jetty {@link org.eclipse.jetty.client.HttpClient}. + * + * @author Jon Schneider + * @since 1.13.0 + */ +public final class JettyClientTags { + + private static final Tag URI_NOT_FOUND = Tag.of("uri", "NOT_FOUND"); + + private static final Tag URI_REDIRECTION = Tag.of("uri", "REDIRECTION"); + + private static final Tag URI_ROOT = Tag.of("uri", "root"); + + private static final Tag EXCEPTION_NONE = Tag.of("exception", "None"); + + private static final Tag METHOD_UNKNOWN = Tag.of("method", "UNKNOWN"); + + private static final Tag HOST_UNKNOWN = Tag.of("host", "UNKNOWN"); + + private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/$"); + + private static final Pattern MULTIPLE_SLASH_PATTERN = Pattern.compile("//+"); + + private JettyClientTags() { + } + + /** + * Creates a {@code method} tag based on the {@link Request#getMethod() method} of the + * given {@code request}. + * @param request the request + * @return the method tag whose value is a capitalized method (e.g. GET). + */ + public static Tag method(Request request) { + return (request != null) ? Tag.of("method", request.getMethod()) : METHOD_UNKNOWN; + } + + /** + * Creates a {@code host} tag based on the {@link Request#getHost()} of the given + * {@code request}. + * @param request the request + * @return the host tag derived from request + * @since 1.7.0 + */ + public static Tag host(Request request) { + return (request != null) ? Tag.of("host", request.getHost()) : HOST_UNKNOWN; + } + + /** + * Creates a {@code status} tag based on the status of the given {@code result}. + * @param result the request result + * @return the status tag derived from the status of the response + */ + public static Tag status(Result result) { + return Tag.of("status", Integer.toString(result.getResponse().getStatus())); + } + + /** + * Creates a {@code uri} tag based on the URI of the given {@code result}. + * {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} for 404 responses. + * @param result the request result + * @param successfulUriPattern successful URI pattern + * @return the uri tag derived from the request result + */ + public static Tag uri(Result result, Function successfulUriPattern) { + Response response = result.getResponse(); + if (response != null) { + int status = response.getStatus(); + if (HttpStatus.isRedirection(status)) { + return URI_REDIRECTION; + } + if (status == 404) { + return URI_NOT_FOUND; + } + } + + String matchingPattern = successfulUriPattern.apply(result); + matchingPattern = MULTIPLE_SLASH_PATTERN.matcher(matchingPattern).replaceAll("/"); + if (matchingPattern.equals("/")) { + return URI_ROOT; + } + matchingPattern = TRAILING_SLASH_PATTERN.matcher(matchingPattern).replaceAll(""); + return Tag.of("uri", matchingPattern); + } + + /** + * Creates an {@code exception} tag based on the {@link Class#getSimpleName() simple + * name} of the class of the given {@code exception}. + * @param result the request result + * @return the exception tag derived from the exception + */ + public static Tag exception(Result result) { + Throwable exception = result.getFailure(); + if (exception == null) { + return EXCEPTION_NONE; + } + if (result.getResponse() != null) { + int status = result.getResponse().getStatus(); + if (status == 404 || HttpStatus.isRedirection(status)) { + return EXCEPTION_NONE; + } + } + if (exception.getCause() != null) { + exception = exception.getCause(); + } + String simpleName = exception.getClass().getSimpleName(); + return Tag.of("exception", StringUtils.isNotEmpty(simpleName) ? simpleName : exception.getClass().getName()); + } + + /** + * Creates an {@code outcome} tag based on the status of the given {@code result}. + * @param result the request result + * @return the outcome tag derived from the status of the response + */ + public static Tag outcome(Result result) { + return Outcome.forStatus(result.getResponse().getStatus()).asTag(); + } + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientTagsProvider.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientTagsProvider.java new file mode 100644 index 0000000000..105ac09082 --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/JettyClientTagsProvider.java @@ -0,0 +1,59 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.core.annotation.Incubating; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import org.eclipse.jetty.client.Result; + +import java.util.function.BiFunction; + +/** + * Provides {@link Tag Tags} for Jetty {@link org.eclipse.jetty.client.HttpClient} request + * metrics. Incubating in case there emerges a better way to handle path variable + * detection. + * + * @author Jon Schneider + * @since 1.13.0 + * @see JettyClientMetrics#builder(MeterRegistry, BiFunction) the builder method to + * configure the uri pattern function with the default tags provider + */ +@Incubating(since = "1.13.0") +public interface JettyClientTagsProvider { + + /** + * Provides tags to be associated with metrics for the given client request and + * result. + * @param result the request result + * @return tags to associate with metrics recorded for the request + */ + default Iterable httpRequestTags(Result result) { + return Tags.of(JettyClientTags.method(result.getRequest()), JettyClientTags.host(result.getRequest()), + JettyClientTags.uri(result, this::uriPattern), JettyClientTags.exception(result), + JettyClientTags.status(result), JettyClientTags.outcome(result)); + } + + /** + * For client metric to be usefully aggregable, we must be able to time everything + * that goes to a certain endpoint, regardless of the parameters to that endpoint. + * @param result The result which also contains the original request. + * @return A URI pattern with path variables and query parameter unsubstituted. + */ + String uriPattern(Result result); + +} diff --git a/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/package-info.java b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/package-info.java new file mode 100644 index 0000000000..64f153085d --- /dev/null +++ b/micrometer-jetty12/src/main/java/io/micrometer/jetty12/client/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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. + */ + +/** + * Instrumentation for Jetty 12. + */ +@NonNullApi +package io.micrometer.jetty12.client; + +import io.micrometer.common.lang.NonNullApi; diff --git a/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/Jetty12ClientTimingInstrumentationVerificationTests.java b/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/Jetty12ClientTimingInstrumentationVerificationTests.java new file mode 100644 index 0000000000..14e57c21df --- /dev/null +++ b/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/Jetty12ClientTimingInstrumentationVerificationTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2022 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import io.micrometer.common.lang.Nullable; +import io.micrometer.core.instrument.HttpClientTimingInstrumentationVerificationTests; +import io.micrometer.observation.docs.ObservationDocumentation; +import org.eclipse.jetty.client.BytesRequestContent; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.Request; + +import java.net.URI; + +class Jetty12ClientTimingInstrumentationVerificationTests + extends HttpClientTimingInstrumentationVerificationTests { + + private static final String HEADER_URI_PATTERN = "URI_PATTERN"; + + @Override + protected String timerName() { + return "jetty.client.requests"; + } + + @Override + protected ObservationDocumentation observationDocumentation() { + return JettyClientObservationDocumentation.DEFAULT; + } + + @Override + protected HttpClient clientInstrumentedWithMetrics() { + return createHttpClient(false); + } + + @Nullable + @Override + protected HttpClient clientInstrumentedWithObservations() { + return createHttpClient(true); + } + + @Override + protected void sendHttpRequest(HttpClient instrumentedClient, HttpMethod method, @Nullable byte[] body, URI baseUri, + String templatedPath, String... pathVariables) { + try { + Request request = instrumentedClient + .newRequest(baseUri + substitutePathVariables(templatedPath, pathVariables)) + .method(method.name()) + .headers(httpFields -> httpFields.add(HEADER_URI_PATTERN, templatedPath)); + if (body != null) { + request.body(new BytesRequestContent(body)); + } + request.send(); + instrumentedClient.stop(); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + + private HttpClient createHttpClient(boolean withObservationRegistry) { + HttpClient httpClient = new HttpClient(); + JettyClientMetrics.Builder builder = JettyClientMetrics.builder(getRegistry(), + (request, result) -> request.getHeaders().get(HEADER_URI_PATTERN)); + if (withObservationRegistry) { + builder.observationRegistry(getObservationRegistry()); + } + httpClient.getRequestListeners().addListener(builder.build()); + try { + httpClient.start(); + } + catch (Exception e) { + throw new RuntimeException(e); + } + return httpClient; + } + +} diff --git a/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/JettyClientMetricsTest.java b/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/JettyClientMetricsTest.java new file mode 100644 index 0000000000..02213b482b --- /dev/null +++ b/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/JettyClientMetricsTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MockClock; +import io.micrometer.core.instrument.simple.SimpleConfig; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.util.component.LifeCycle; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; + +@WireMockTest +class JettyClientMetricsTest { + + protected SimpleMeterRegistry registry = new SimpleMeterRegistry(SimpleConfig.DEFAULT, new MockClock()); + + protected CountDownLatch singleRequestLatch = new CountDownLatch(1); + + protected HttpClient httpClient = new HttpClient(); + + @BeforeEach + void beforeEach() throws Exception { + httpClient.setFollowRedirects(false); + addInstrumentingListener(); + + httpClient.addEventListener(new LifeCycle.Listener() { + @Override + public void lifeCycleStopped(LifeCycle event) { + singleRequestLatch.countDown(); + } + }); + + httpClient.start(); + + } + + protected void addInstrumentingListener() { + httpClient.getRequestListeners() + .addListener(JettyClientMetrics.builder(registry, (request, result) -> request.getURI().getPath()).build()); + } + + @Test + void successfulHttpPostRequest(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(post("/ok").willReturn(ok())); + + Request post = httpClient.POST("http://localhost:" + wmRuntimeInfo.getHttpPort() + "/ok"); + post.body(new StringRequestContent("123456")); + post.send(); + httpClient.stop(); + + assertThat(singleRequestLatch.await(10, SECONDS)).isTrue(); + assertThat(registry.get("jetty.client.requests") + .tag("outcome", "SUCCESS") + .tag("status", "200") + .tag("uri", "/ok") + .tag("host", "localhost") + .timer() + .count()).isEqualTo(1); + } + + @Test + void successfulHttpGetRequest(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(get("/ok").willReturn(ok())); + + httpClient.GET("http://localhost:" + wmRuntimeInfo.getHttpPort() + "/ok"); + httpClient.stop(); + + assertThat(singleRequestLatch.await(10, SECONDS)).isTrue(); + assertThat(registry.get("jetty.client.requests") + .tag("outcome", "SUCCESS") + .tag("status", "200") + .tag("uri", "/ok") + .timer() + .count()).isEqualTo(1); + DistributionSummary requestSizeSummary = registry.get("jetty.client.request.size").summary(); + assertThat(requestSizeSummary.count()).isEqualTo(1); + assertThat(requestSizeSummary.totalAmount()).isEqualTo(0); + } + + @Test + void requestSize(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(post("/ok").willReturn(ok())); + + Request post = httpClient.POST("http://localhost:" + wmRuntimeInfo.getHttpPort() + "/ok"); + post.body(new StringRequestContent("123456")); + post.send(); + httpClient.stop(); + + assertThat(singleRequestLatch.await(10, SECONDS)).isTrue(); + assertThat(registry.get("jetty.client.request.size") + .tag("outcome", "SUCCESS") + .tag("status", "200") + .tag("uri", "/ok") + .tag("host", "localhost") + .summary() + .totalAmount()).isEqualTo("123456".length()); + } + + @Test + void serverError(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(post("/error").willReturn(WireMock.serverError())); + + Request post = httpClient.POST("http://localhost:" + wmRuntimeInfo.getHttpPort() + "/error"); + post.body(new StringRequestContent("123456")); + post.send(); + httpClient.stop(); + + assertThat(singleRequestLatch.await(10, SECONDS)).isTrue(); + assertThat(registry.get("jetty.client.requests") + .tag("outcome", "SERVER_ERROR") + .tag("status", "500") + .tag("uri", "/error") + .tag("host", "localhost") + .timer() + .count()).isEqualTo(1); + } + + @Test + void notFound(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + Request post = httpClient.POST("http://localhost:" + wmRuntimeInfo.getHttpPort() + "/doesNotExist"); + post.body(new StringRequestContent("123456")); + post.send(); + httpClient.stop(); + + assertThat(singleRequestLatch.await(10, SECONDS)).isTrue(); + assertThat(registry.get("jetty.client.requests") + .tag("outcome", "CLIENT_ERROR") + .tag("status", "404") + .tag("uri", "NOT_FOUND") + .tag("host", "localhost") + .timer() + .count()).isEqualTo(1); + } + +} diff --git a/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/JettyClientMetricsWithObservationTest.java b/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/JettyClientMetricsWithObservationTest.java new file mode 100644 index 0000000000..6455874c1b --- /dev/null +++ b/micrometer-jetty12/src/test/java/io/micrometer/jetty12/client/JettyClientMetricsWithObservationTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024 VMware, Inc. + * + * 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 + * + * https://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.micrometer.jetty12.client; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import io.micrometer.core.instrument.observation.DefaultMeterObservationHandler; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; + +class JettyClientMetricsWithObservationTest extends JettyClientMetricsTest { + + private final ObservationRegistry observationRegistry = TestObservationRegistry.create(); + + @BeforeEach + @Override + void beforeEach() throws Exception { + observationRegistry.observationConfig().observationHandler(new DefaultMeterObservationHandler(registry)); + super.beforeEach(); + } + + @Override + protected void addInstrumentingListener() { + this.httpClient.getRequestListeners() + .addListener(JettyClientMetrics.builder(registry, (request, result) -> request.getURI().getPath()) + .observationRegistry(observationRegistry) + .build()); + } + + @Test + void activeTimer(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(get("/ok").willReturn(ok())); + + httpClient.GET("http://localhost:" + wmRuntimeInfo.getHttpPort() + "/ok"); + assertThat(registry.get("jetty.client.requests.active") + .tags("uri", "/ok", "method", "GET") + .longTaskTimer() + .activeTasks()).isOne(); + httpClient.stop(); + + assertThat(singleRequestLatch.await(10, SECONDS)).isTrue(); + assertThat(registry.get("jetty.client.requests") + .tag("outcome", "SUCCESS") + .tag("status", "200") + .tag("uri", "/ok") + .timer() + .count()).isEqualTo(1); + } + +}