diff --git a/httpclient5-observation/pom.xml b/httpclient5-observation/pom.xml new file mode 100644 index 0000000000..6cb5260aaa --- /dev/null +++ b/httpclient5-observation/pom.xml @@ -0,0 +1,151 @@ + + + 4.0.0 + + org.apache.httpcomponents.client5 + httpclient5-parent + 5.6-alpha1-SNAPSHOT + + + httpclient5-observation + Apache HttpClient Observation + Optional Micrometer / OpenTelemetry support for HttpClient + jar + + + + + io.opentelemetry + opentelemetry-bom + 1.49.0 + pom + import + + + + + + org.apache.httpcomponents.client5.observation + + + + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.apache.httpcomponents.client5 + httpclient5-cache + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-slf4j-impl + true + + + org.apache.logging.log4j + log4j-core + test + + + io.micrometer + micrometer-core + + + io.micrometer + micrometer-observation + + + io.micrometer + micrometer-registry-prometheus + + + io.opentelemetry + opentelemetry-sdk + runtime + + + io.micrometer + micrometer-tracing-bridge-otel + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + org.junit.jupiter + junit-jupiter + test + + + org.apache.commons + commons-compress + test + + + + io.micrometer + micrometer-tracing + ${micrometer.tracing.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + com.github.siom79.japicmp + japicmp-maven-plugin + + true + + + + + + diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/HttpClientObservationSupport.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/HttpClientObservationSupport.java new file mode 100644 index 0000000000..085293d005 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/HttpClientObservationSupport.java @@ -0,0 +1,473 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.observation.ObservationRegistry; +import org.apache.hc.client5.http.impl.ChainElement; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.cache.CachingHttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.cache.CachingHttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.observation.binder.ConnPoolMeters; +import org.apache.hc.client5.http.observation.binder.ConnPoolMetersAsync; +import org.apache.hc.client5.http.observation.impl.ObservationAsyncExecInterceptor; +import org.apache.hc.client5.http.observation.impl.ObservationClassicExecInterceptor; +import org.apache.hc.client5.http.observation.interceptors.AsyncIoByteCounterExec; +import org.apache.hc.client5.http.observation.interceptors.AsyncTimerExec; +import org.apache.hc.client5.http.observation.interceptors.IoByteCounterExec; +import org.apache.hc.client5.http.observation.interceptors.TimerExec; +import org.apache.hc.core5.util.Args; + +/** + * Utility class that wires Micrometer / OpenTelemetry instrumentation into + * the HttpClient execution pipeline(s). + * + *

This helper can install:

+ * + * + *

Optional dependencies

+ *

+ * Micrometer and OpenTelemetry are optional dependencies. Use the + * overloads that accept explicit registries (recommended) or the convenience + * overloads that use {@link Metrics#globalRegistry}. When Micrometer is not + * on the classpath, only the observation / metric features you actually call + * will be required. + *

+ * + *

Typical usage (classic client)

+ *
{@code
+ * ObservationRegistry obs = ObservationRegistry.create();
+ * MeterRegistry meters = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
+ * HttpClientBuilder b = HttpClients.custom();
+ *
+ * HttpClientObservationSupport.enable(
+ *     b, obs, meters,
+ *     ObservingOptions.DEFAULT,
+ *     MetricConfig.DEFAULT);
+ *
+ * CloseableHttpClient client = b.build();
+ * }
+ * + *

What gets installed

+ * + * + *

Thread safety: This class is stateless. Methods may be + * called from any thread before the client is built.

+ * + * @since 5.6 + */ +public final class HttpClientObservationSupport { + + /** + * Internal ID for the observation interceptor. + */ + private static final String OBS_ID = "observation"; + /** + * Internal ID for latency/counter metrics interceptor. + */ + private static final String TIMER_ID = "metric-timer"; + /** + * Internal ID for I/O byte counters interceptor. + */ + private static final String IO_ID = "metric-io"; + + /* ====================== Classic ====================== */ + + /** + * Enables observations and default metrics on a classic client builder using + * {@link Metrics#globalRegistry} and {@link MetricConfig#DEFAULT}. + * + * @param builder client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @since 5.6 + */ + public static void enable(final HttpClientBuilder builder, + final ObservationRegistry obsReg) { + enable(builder, obsReg, Metrics.globalRegistry, ObservingOptions.DEFAULT, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a classic client builder using + * {@link Metrics#globalRegistry} and a custom {@link ObservingOptions}. + * + * @param builder client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final HttpClientBuilder builder, + final ObservationRegistry obsReg, + final ObservingOptions opts) { + enable(builder, obsReg, Metrics.globalRegistry, opts, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a classic client builder with an explicit + * meter registry and default {@link MetricConfig}. + * + * @param builder client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param meterReg meter registry to register meters with + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final HttpClientBuilder builder, + final ObservationRegistry obsReg, + final MeterRegistry meterReg, + final ObservingOptions opts) { + enable(builder, obsReg, meterReg, opts, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a classic client builder using explicit + * registries and {@link MetricConfig}. + * + *

Installs interceptors at the beginning of the execution chain.

+ * + * @param builder client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param meterReg meter registry to register meters with (must not be {@code null}) + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @param mc metric configuration; when {@code null} {@link MetricConfig#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final HttpClientBuilder builder, + final ObservationRegistry obsReg, + final MeterRegistry meterReg, + final ObservingOptions opts, + final MetricConfig mc) { + + Args.notNull(builder, "builder"); + Args.notNull(meterReg, "meterRegistry"); + + final ObservingOptions o = (opts != null) ? opts : ObservingOptions.DEFAULT; + final MetricConfig config = (mc != null) ? mc : MetricConfig.DEFAULT; + + // Observations (spans) — only if registry provided + if (obsReg != null) { + builder.addExecInterceptorFirst(OBS_ID, new ObservationClassicExecInterceptor(obsReg, opts)); + } + + // Metrics + if (o.metricSets.contains(ObservingOptions.MetricSet.BASIC)) { + builder.addExecInterceptorFirst(TIMER_ID, new TimerExec(meterReg, o, config)); + } + if (o.metricSets.contains(ObservingOptions.MetricSet.IO)) { + builder.addExecInterceptorFirst(IO_ID, new IoByteCounterExec(meterReg, o, config)); + } + if (o.metricSets.contains(ObservingOptions.MetricSet.CONN_POOL)) { + ConnPoolMeters.bindTo(builder, meterReg, config); + } + } + + /* ============== Classic (with caching) =============== */ + + /** + * Enables observations and default metrics on a caching classic client builder using + * {@link Metrics#globalRegistry} and {@link MetricConfig#DEFAULT}. + * + * @param builder caching client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @since 5.6 + */ + public static void enable(final CachingHttpClientBuilder builder, + final ObservationRegistry obsReg) { + enable(builder, obsReg, Metrics.globalRegistry, ObservingOptions.DEFAULT, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a caching classic client builder using + * {@link Metrics#globalRegistry} and a custom {@link ObservingOptions}. + * + * @param builder caching client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final CachingHttpClientBuilder builder, + final ObservationRegistry obsReg, + final ObservingOptions opts) { + enable(builder, obsReg, Metrics.globalRegistry, opts, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a caching classic client builder with an explicit + * meter registry and default {@link MetricConfig}. + * + * @param builder caching client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param meterReg meter registry to register meters with + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final CachingHttpClientBuilder builder, + final ObservationRegistry obsReg, + final MeterRegistry meterReg, + final ObservingOptions opts) { + enable(builder, obsReg, meterReg, opts, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a caching classic client builder using explicit + * registries and {@link MetricConfig}. + * + *

Interceptors are installed after the caching element so that + * metrics/observations reflect the actual exchange.

+ * + * @param builder caching client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param meterReg meter registry to register meters with (must not be {@code null}) + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @param mc metric configuration; when {@code null} {@link MetricConfig#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final CachingHttpClientBuilder builder, + final ObservationRegistry obsReg, + final MeterRegistry meterReg, + final ObservingOptions opts, + final MetricConfig mc) { + + Args.notNull(builder, "builder"); + Args.notNull(meterReg, "meterRegistry"); + + final ObservingOptions o = (opts != null) ? opts : ObservingOptions.DEFAULT; + final MetricConfig config = (mc != null) ? mc : MetricConfig.DEFAULT; + + // Observations (after caching stage so they see the real exchange) + if (obsReg != null) { + builder.addExecInterceptorAfter(ChainElement.CACHING.name(), OBS_ID, + new ObservationClassicExecInterceptor(obsReg, opts)); + } + + // Metrics + if (o.metricSets.contains(ObservingOptions.MetricSet.BASIC)) { + builder.addExecInterceptorAfter(ChainElement.CACHING.name(), TIMER_ID, new TimerExec(meterReg, o, config)); + } + if (o.metricSets.contains(ObservingOptions.MetricSet.IO)) { + builder.addExecInterceptorAfter(ChainElement.CACHING.name(), IO_ID, new IoByteCounterExec(meterReg, o, config)); + } + if (o.metricSets.contains(ObservingOptions.MetricSet.CONN_POOL)) { + ConnPoolMeters.bindTo(builder, meterReg, config); + } + } + + /* ======================== Async ====================== */ + + /** + * Enables observations and default metrics on an async client builder using + * {@link Metrics#globalRegistry} and {@link MetricConfig#DEFAULT}. + * + * @param builder async client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @since 5.6 + */ + public static void enable(final HttpAsyncClientBuilder builder, + final ObservationRegistry obsReg) { + enable(builder, obsReg, Metrics.globalRegistry, ObservingOptions.DEFAULT, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on an async client builder using + * {@link Metrics#globalRegistry} and a custom {@link ObservingOptions}. + * + * @param builder async client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final HttpAsyncClientBuilder builder, + final ObservationRegistry obsReg, + final ObservingOptions opts) { + enable(builder, obsReg, Metrics.globalRegistry, opts, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on an async client builder with an explicit + * meter registry and default {@link MetricConfig}. + * + * @param builder async client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param meterReg meter registry to register meters with + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final HttpAsyncClientBuilder builder, + final ObservationRegistry obsReg, + final MeterRegistry meterReg, + final ObservingOptions opts) { + enable(builder, obsReg, meterReg, opts, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on an async client builder using explicit + * registries and {@link MetricConfig}. + * + * @param builder async client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param meterReg meter registry to register meters with (must not be {@code null}) + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @param mc metric configuration; when {@code null} {@link MetricConfig#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final HttpAsyncClientBuilder builder, + final ObservationRegistry obsReg, + final MeterRegistry meterReg, + final ObservingOptions opts, + final MetricConfig mc) { + + Args.notNull(builder, "builder"); + Args.notNull(meterReg, "meterRegistry"); + + final ObservingOptions o = opts != null ? opts : ObservingOptions.DEFAULT; + final MetricConfig config = mc != null ? mc : MetricConfig.DEFAULT; + + // Observations + if (obsReg != null) { + builder.addExecInterceptorFirst(OBS_ID, new ObservationAsyncExecInterceptor(obsReg, o)); + } + + // Metrics + if (o.metricSets.contains(ObservingOptions.MetricSet.BASIC)) { + builder.addExecInterceptorFirst(TIMER_ID, new AsyncTimerExec(meterReg, o, config)); + } + if (o.metricSets.contains(ObservingOptions.MetricSet.IO)) { + builder.addExecInterceptorFirst(IO_ID, new AsyncIoByteCounterExec(meterReg, o, config)); + } + if (o.metricSets.contains(ObservingOptions.MetricSet.CONN_POOL)) { + ConnPoolMetersAsync.bindTo(builder, meterReg, config); + } + } + + /* ============== Async (with caching) ================= */ + + /** + * Enables observations and default metrics on a caching async client builder using + * {@link Metrics#globalRegistry} and {@link MetricConfig#DEFAULT}. + * + * @param builder caching async client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @since 5.6 + */ + public static void enable(final CachingHttpAsyncClientBuilder builder, + final ObservationRegistry obsReg) { + enable(builder, obsReg, Metrics.globalRegistry, ObservingOptions.DEFAULT, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a caching async client builder using + * {@link Metrics#globalRegistry} and a custom {@link ObservingOptions}. + * + * @param builder caching async client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final CachingHttpAsyncClientBuilder builder, + final ObservationRegistry obsReg, + final ObservingOptions opts) { + enable(builder, obsReg, Metrics.globalRegistry, opts, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a caching async client builder with an explicit + * meter registry and default {@link MetricConfig}. + * + * @param builder caching async client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param meterReg meter registry to register meters with + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final CachingHttpAsyncClientBuilder builder, + final ObservationRegistry obsReg, + final MeterRegistry meterReg, + final ObservingOptions opts) { + enable(builder, obsReg, meterReg, opts, MetricConfig.DEFAULT); + } + + /** + * Enables observations and metrics on a caching async client builder using explicit + * registries and {@link MetricConfig}. + * + *

Interceptors are installed after the caching element.

+ * + * @param builder caching async client builder to instrument + * @param obsReg observation registry; if {@code null} no observations are installed + * @param meterReg meter registry to register meters with (must not be {@code null}) + * @param opts observation/metric options; when {@code null} {@link ObservingOptions#DEFAULT} is used + * @param mc metric configuration; when {@code null} {@link MetricConfig#DEFAULT} is used + * @since 5.6 + */ + public static void enable(final CachingHttpAsyncClientBuilder builder, + final ObservationRegistry obsReg, + final MeterRegistry meterReg, + final ObservingOptions opts, + final MetricConfig mc) { + + Args.notNull(builder, "builder"); + Args.notNull(meterReg, "meterRegistry"); + + final ObservingOptions o = opts != null ? opts : ObservingOptions.DEFAULT; + final MetricConfig config = mc != null ? mc : MetricConfig.DEFAULT; + + // Observations (after caching) + if (obsReg != null) { + builder.addExecInterceptorAfter(ChainElement.CACHING.name(), OBS_ID, + new ObservationAsyncExecInterceptor(obsReg, o)); + } + + // Metrics + if (o.metricSets.contains(ObservingOptions.MetricSet.BASIC)) { + builder.addExecInterceptorAfter(ChainElement.CACHING.name(), TIMER_ID, new AsyncTimerExec(meterReg, o, config)); + } + if (o.metricSets.contains(ObservingOptions.MetricSet.IO)) { + builder.addExecInterceptorAfter(ChainElement.CACHING.name(), IO_ID, new AsyncIoByteCounterExec(meterReg, o, config)); + } + } + + /** + * No instantiation. + * + * @since 5.6 + */ + private HttpClientObservationSupport() { + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/MetricConfig.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/MetricConfig.java new file mode 100644 index 0000000000..74da0b44f1 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/MetricConfig.java @@ -0,0 +1,135 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.micrometer.core.instrument.Tag; + +/** + * Tunables for Micrometer metrics: SLOs, percentiles, common tags, + * optional high-cardinality URI tagging for I/O counters. + * + * @since 5.6 + */ +public final class MetricConfig { + + /** + * Metric name prefix; defaults to "http.client". + */ + public final String prefix; + + /** + * Service-level objective for latency histograms. + */ + public final Duration slo; + + /** + * Percentiles to publish (e.g., 0.95, 0.99). Empty => none. + */ + public final double[] percentiles; + + /** + * If true, IO counters get a "uri" tag (can be high-cardinality). + */ + public final boolean perUriIo; + + /** + * Tags added to every meter. + */ + public final List commonTags; + + private MetricConfig(final Builder b) { + this.prefix = b.prefix; + this.slo = b.slo; + this.percentiles = b.percentiles != null ? b.percentiles.clone() : new double[0]; + this.perUriIo = b.perUriIo; + this.commonTags = Collections.unmodifiableList(new ArrayList<>(b.commonTags)); + } + + public static final MetricConfig DEFAULT = builder().build(); + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String prefix = "http.client"; + private Duration slo = Duration.ofMillis(500); + private double[] percentiles = new double[]{0.90, 0.99}; + private boolean perUriIo = false; + private final List commonTags = new ArrayList<>(); + + public Builder prefix(final String p) { + this.prefix = p; + return this; + } + + public Builder slo(final Duration d) { + this.slo = d; + return this; + } + + public Builder percentiles(final double... p) { + if (p != null) { + for (final double d : p) { + if (d < 0.0 || d > 1.0) { + throw new IllegalArgumentException("percentile out of range [0..1]: " + d); + } + } + this.percentiles = p.clone(); + } else { + this.percentiles = new double[0]; + } + return this; + } + + public Builder perUriIo(final boolean b) { + this.perUriIo = b; + return this; + } + + public Builder addCommonTag(final String k, final String v) { + this.commonTags.add(Tag.of(k, v)); + return this; + } + + public Builder addCommonTags(final Iterable tags) { + for (final Tag t : tags) { + this.commonTags.add(t); + } + return this; + } + + public MetricConfig build() { + return new MetricConfig(this); + } + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/ObservingOptions.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/ObservingOptions.java new file mode 100644 index 0000000000..a6f8c6c856 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/ObservingOptions.java @@ -0,0 +1,132 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation; + +import java.util.EnumSet; +import java.util.List; +import java.util.function.Predicate; + +import io.micrometer.core.instrument.Tag; +import io.micrometer.observation.ObservationPredicate; +import org.apache.hc.core5.util.Args; + +/** + * Immutable container with all user–tunable knobs for metrics/tracing. + * + * @since 5.6 + */ +public final class ObservingOptions { + + /** + * Which metric groups to enable. + */ + public enum MetricSet { BASIC, IO, CONN_POOL, TLS, DNS } + + /** + * How many tags each metric/trace should get. + */ + public enum TagLevel { LOW, EXTENDED } + + /** + * Per-request tag customization hook. + */ + @FunctionalInterface + public interface TagCustomizer { + List apply(List base, + String method, + int status, + String protocol, + String target, + String uri); + } + + /** + * Convenience: turn on all metric groups. + */ + public static EnumSet allMetricSets() { + return EnumSet.allOf(MetricSet.class); + } + + public final EnumSet metricSets; + public final TagLevel tagLevel; + public final ObservationPredicate micrometerFilter; + public final Predicate spanSampling; + public final TagCustomizer tagCustomizer; + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private EnumSet sets = EnumSet.of(MetricSet.BASIC); + private TagLevel tag = TagLevel.LOW; + private ObservationPredicate obs = (n, c) -> true; + private Predicate span = uri -> true; + private TagCustomizer customizer = (base, m, s, p, t, u) -> { + return base; // identity by default + }; + + public Builder metrics(final EnumSet s) { + sets = EnumSet.copyOf(s); + return this; + } + + public Builder tagLevel(final TagLevel t) { + tag = Args.notNull(t, "tag"); + return this; + } + + public Builder micrometerFilter(final ObservationPredicate p) { + obs = Args.notNull(p, "pred"); + return this; + } + + public Builder spanSampling(final Predicate p) { + span = Args.notNull(p, "pred"); + return this; + } + + public Builder tagCustomizer(final TagCustomizer c) { + customizer = Args.notNull(c, "tagCustomizer"); + return this; + } + + public ObservingOptions build() { + return new ObservingOptions(this); + } + } + + public static final ObservingOptions DEFAULT = builder().build(); + + private ObservingOptions(final Builder b) { + metricSets = b.sets; + tagLevel = b.tag; + micrometerFilter = b.obs; + spanSampling = b.span; + tagCustomizer = b.customizer; + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/ConnPoolMeters.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/ConnPoolMeters.java new file mode 100644 index 0000000000..e645245f05 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/ConnPoolMeters.java @@ -0,0 +1,133 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.binder; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.MeterBinder; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.core5.pool.ConnPoolControl; +import org.apache.hc.core5.util.Args; + +/** + * Registers connection pool gauges for a classic {@link HttpClientBuilder}. + *

+ * Exposes: + *

    + *
  • {@code <prefix>.pool.leased} – number of leased connections
  • + *
  • {@code <prefix>.pool.available} – number of available (idle) connections
  • + *
  • {@code <prefix>.pool.pending} – number of pending connection requests
  • + *
+ * The {@code prefix} and any common tags come from {@link MetricConfig}. + * + *

Usage

+ *
{@code
+ * MeterRegistry meters = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
+ * MetricConfig mc = MetricConfig.builder().prefix("http_client").build();
+ *
+ * HttpClientBuilder b = HttpClients.custom()
+ *     .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create().build());
+ *
+ * // after the connection manager is attached to the builder:
+ * ConnPoolMeters.bindTo(b, meters, mc);
+ * }
+ * + *

Note: This binder reads the connection manager from the + * {@link HttpClientBuilder}. Ensure the builder has a compatible connection + * manager (implementing {@link ConnPoolControl}) before calling {@code bindTo}.

+ * + * @since 5.6 + */ +public final class ConnPoolMeters implements MeterBinder { + + private final ConnPoolControl pool; + private final MetricConfig mc; + + private ConnPoolMeters(final ConnPoolControl pool, final MetricConfig mc) { + this.pool = pool; + this.mc = mc; + } + + @Override + public void bindTo(final MeterRegistry registry) { + Args.notNull(registry, "registry"); + Gauge.builder(mc.prefix + ".pool.leased", pool, p -> p.getTotalStats().getLeased()) + .tags(mc.commonTags) + .register(registry); + Gauge.builder(mc.prefix + ".pool.available", pool, p -> p.getTotalStats().getAvailable()) + .tags(mc.commonTags) + .register(registry); + Gauge.builder(mc.prefix + ".pool.pending", pool, p -> p.getTotalStats().getPending()) + .tags(mc.commonTags) + .register(registry); + } + + /** + * Binds pool gauges for the connection manager currently attached to the given builder. + * If the builder has no connection manager or it does not implement {@link ConnPoolControl}, + * this method is a no-op. + * + * @param builder classic client builder + * @param registry meter registry + * @param mc metric configuration (prefix, common tags) + * @since 5.6 + */ + public static void bindTo(final HttpClientBuilder builder, + final MeterRegistry registry, + final MetricConfig mc) { + Args.notNull(builder, "builder"); + Args.notNull(registry, "registry"); + Args.notNull(mc, "metricConfig"); + + final HttpClientConnectionManager cm = builder.getConnManager(); + if (cm instanceof ConnPoolControl) { + new ConnPoolMeters((ConnPoolControl) cm, mc).bindTo(registry); + } + } + + /** + * Binds pool gauges using {@link MetricConfig#DEFAULT}. + * + * @param builder classic client builder + * @param registry meter registry + * @since 5.6 + */ + public static void bindTo(final HttpClientBuilder builder, + final MeterRegistry registry) { + bindTo(builder, registry, MetricConfig.DEFAULT); + } + + /** + * No instantiation outside helpers. + */ + private ConnPoolMeters() { + this.pool = null; + this.mc = null; + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersAsync.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersAsync.java new file mode 100644 index 0000000000..59ec5a9436 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersAsync.java @@ -0,0 +1,133 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.binder; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.MeterBinder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.nio.AsyncClientConnectionManager; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.core5.pool.ConnPoolControl; +import org.apache.hc.core5.util.Args; + +/** + * Registers connection-pool gauges for an {@link HttpAsyncClientBuilder}. + *

+ * Exposes: + *

    + *
  • {@code <prefix>.pool.leased} – number of leased connections
  • + *
  • {@code <prefix>.pool.available} – number of available (idle) connections
  • + *
  • {@code <prefix>.pool.pending} – number of pending connection requests
  • + *
+ * The {@code prefix} and any common tags come from {@link MetricConfig}. + * + *

Usage

+ *
{@code
+ * MeterRegistry meters = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
+ * MetricConfig mc = MetricConfig.builder().prefix("http_client").build();
+ *
+ * HttpAsyncClientBuilder b = HttpAsyncClients.custom()
+ *     .setConnectionManager(PoolingAsyncClientConnectionManagerBuilder.create().build());
+ *
+ * // After the async connection manager is attached to the builder:
+ * ConnPoolMetersAsync.bindTo(b, meters, mc);
+ * }
+ * + *

Note: This binder reads the async connection manager from + * the {@link HttpAsyncClientBuilder}. If the builder has no manager attached or + * it does not implement {@link ConnPoolControl}, this method is a no-op.

+ * + * @since 5.6 + */ +public final class ConnPoolMetersAsync implements MeterBinder { + + private final ConnPoolControl pool; + private final MetricConfig mc; + + private ConnPoolMetersAsync(final ConnPoolControl pool, final MetricConfig mc) { + this.pool = pool; + this.mc = mc; + } + + @Override + public void bindTo(final MeterRegistry registry) { + Args.notNull(registry, "registry"); + Gauge.builder(mc.prefix + ".pool.leased", pool, p -> p.getTotalStats().getLeased()) + .tags(mc.commonTags) + .register(registry); + Gauge.builder(mc.prefix + ".pool.available", pool, p -> p.getTotalStats().getAvailable()) + .tags(mc.commonTags) + .register(registry); + Gauge.builder(mc.prefix + ".pool.pending", pool, p -> p.getTotalStats().getPending()) + .tags(mc.commonTags) + .register(registry); + } + + /** + * Binds pool gauges for the async connection manager currently attached to the builder. + * If the builder has no connection manager or it does not implement {@link ConnPoolControl}, + * this method is a no-op. + * + * @param builder async client builder + * @param registry meter registry + * @param mc metric configuration (prefix, common tags) + * @since 5.6 + */ + public static void bindTo(final HttpAsyncClientBuilder builder, + final MeterRegistry registry, + final MetricConfig mc) { + Args.notNull(builder, "builder"); + Args.notNull(registry, "registry"); + Args.notNull(mc, "metricConfig"); + + final AsyncClientConnectionManager cm = builder.getConnManager(); + if (cm instanceof ConnPoolControl) { + new ConnPoolMetersAsync((ConnPoolControl) cm, mc).bindTo(registry); + } + } + + /** + * Binds pool gauges using {@link MetricConfig#DEFAULT}. + * + * @param builder async client builder + * @param registry meter registry + * @since 5.6 + */ + public static void bindTo(final HttpAsyncClientBuilder builder, + final MeterRegistry registry) { + bindTo(builder, registry, MetricConfig.DEFAULT); + } + + /** + * No instantiation outside helpers. + */ + private ConnPoolMetersAsync() { + this.pool = null; + this.mc = null; + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/package-info.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/package-info.java new file mode 100644 index 0000000000..8e33392c80 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/package-info.java @@ -0,0 +1,32 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Simple facade APIs for HttpClient based on the concept of + * an observation binder interface. + */ +package org.apache.hc.client5.http.observation.binder; diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/MeteredDnsResolver.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/MeteredDnsResolver.java new file mode 100644 index 0000000000..24caf6fc3c --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/MeteredDnsResolver.java @@ -0,0 +1,135 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.impl; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.util.Args; + +/** + * {@link DnsResolver} wrapper that records DNS resolution metrics via Micrometer. + *

+ * Exposes the following meters (names are prefixed by {@link MetricConfig#prefix}): + *

    + *
  • {@code <prefix>.dns.resolve} (timer) — latency of {@link #resolve(String)}
  • + *
  • {@code <prefix>.dns.resolutions} (counter) — outcome-count of {@link #resolve(String)}
  • + *
  • {@code <prefix>.dns.canonical} (timer) — latency of {@link #resolveCanonicalHostname(String)}
  • + *
  • {@code <prefix>.dns.canonicals} (counter) — outcome-count of {@link #resolveCanonicalHostname(String)}
  • + *
+ * Tags: + *
    + *
  • {@code result} = {@code ok}|{@code error}
  • + *
  • {@code host} (only when {@link ObservingOptions.TagLevel#EXTENDED})
  • + *
  • plus any {@link MetricConfig#commonTags common tags}
  • + *
+ * + * @since 5.6 + */ +public final class MeteredDnsResolver implements DnsResolver { + + private final DnsResolver delegate; + private final MeterRegistry registry; + private final MetricConfig mc; + private final ObservingOptions opts; + + /** + * @param delegate underlying resolver + * @param registry meter registry + * @param mc metric configuration (prefix, common tags). If {@code null}, defaults are used. + * @param opts observing options (for tag level). If {@code null}, {@link ObservingOptions#DEFAULT} is used. + */ + public MeteredDnsResolver(final DnsResolver delegate, + final MeterRegistry registry, + final MetricConfig mc, + final ObservingOptions opts) { + this.delegate = Args.notNull(delegate, "delegate"); + this.registry = Args.notNull(registry, "registry"); + this.mc = mc != null ? mc : MetricConfig.builder().build(); + this.opts = opts != null ? opts : ObservingOptions.DEFAULT; + } + + private List tags(final String result, final String host) { + final List ts = new ArrayList<>(2); + ts.add(Tag.of("result", result)); + if (opts.tagLevel == ObservingOptions.TagLevel.EXTENDED && host != null) { + ts.add(Tag.of("host", host)); + } + if (!mc.commonTags.isEmpty()) { + ts.addAll(mc.commonTags); + } + return ts; + } + + @Override + public InetAddress[] resolve(final String host) throws UnknownHostException { + final long t0 = System.nanoTime(); + try { + final InetAddress[] out = delegate.resolve(host); + final List t = tags("ok", host); + Timer.builder(mc.prefix + ".dns.resolve").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".dns.resolutions").tags(t).register(registry).increment(); + return out; + } catch (final UnknownHostException ex) { + final List t = tags("error", host); + Timer.builder(mc.prefix + ".dns.resolve").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".dns.resolutions").tags(t).register(registry).increment(); + throw ex; + } + } + + @Override + public String resolveCanonicalHostname(final String host) throws UnknownHostException { + final long t0 = System.nanoTime(); + try { + final String out = delegate.resolveCanonicalHostname(host); + final List t = tags("ok", host); + Timer.builder(mc.prefix + ".dns.canonical").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".dns.canonicals").tags(t).register(registry).increment(); + return out; + } catch (final UnknownHostException ex) { + final List t = tags("error", host); + Timer.builder(mc.prefix + ".dns.canonical").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".dns.canonicals").tags(t).register(registry).increment(); + throw ex; + } + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/MeteredTlsStrategy.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/MeteredTlsStrategy.java new file mode 100644 index 0000000000..e0a1a7203a --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/MeteredTlsStrategy.java @@ -0,0 +1,202 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.impl; + +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; + +/** + * {@link TlsStrategy} decorator that records TLS handshake metrics via Micrometer. + *

+ * Exposes the following meters (names are prefixed by {@link MetricConfig#prefix}): + *

    + *
  • {@code <prefix>.tls.handshake} (timer) — TLS handshake latency
  • + *
  • {@code <prefix>.tls.handshakes} (counter) — handshake outcome count
  • + *
+ * Tags: + *
    + *
  • {@code result} = {@code ok}|{@code error}|{@code cancel}
  • + *
  • {@code sni} (only when {@link ObservingOptions.TagLevel#EXTENDED})
  • + *
  • plus any {@link MetricConfig#commonTags common tags}
  • + *
+ * + * @since 5.6 + */ +public final class MeteredTlsStrategy implements TlsStrategy { + + private final TlsStrategy delegate; + private final MeterRegistry registry; + private final MetricConfig mc; + private final ObservingOptions opts; + + /** + * Primary constructor. + * + * @param delegate TLS strategy to wrap + * @param registry meter registry + * @param mc metric configuration (prefix, common tags). If {@code null}, defaults are used. + * @param opts observing options (tag level). If {@code null}, {@link ObservingOptions#DEFAULT} is used. + */ + public MeteredTlsStrategy(final TlsStrategy delegate, + final MeterRegistry registry, + final MetricConfig mc, + final ObservingOptions opts) { + this.delegate = Args.notNull(delegate, "delegate"); + this.registry = Args.notNull(registry, "registry"); + this.mc = mc != null ? mc : MetricConfig.builder().build(); + this.opts = opts != null ? opts : ObservingOptions.DEFAULT; + } + + /** + * Convenience constructor. + * + * @deprecated Use + * {@link #MeteredTlsStrategy(TlsStrategy, MeterRegistry, MetricConfig, ObservingOptions)} + * supplying {@link MetricConfig} and {@link ObservingOptions}. + */ + @Deprecated + public MeteredTlsStrategy(final TlsStrategy delegate, + final MeterRegistry registry, + final String prefix) { + this(delegate, registry, + MetricConfig.builder().prefix(prefix != null ? prefix : "hc").build(), + ObservingOptions.DEFAULT); + } + + private List tags(final String result, final String sniOrNull) { + final List ts = new ArrayList<>(2); + ts.add(Tag.of("result", result)); + if (opts.tagLevel == ObservingOptions.TagLevel.EXTENDED && sniOrNull != null) { + ts.add(Tag.of("sni", sniOrNull)); + } + if (!mc.commonTags.isEmpty()) { + ts.addAll(mc.commonTags); + } + return ts; + } + + + @Override + public void upgrade( + final TransportSecurityLayer sessionLayer, + final NamedEndpoint endpoint, + final Object attachment, + final Timeout handshakeTimeout, + final FutureCallback callback) { + + final long t0 = System.nanoTime(); + final String sni = endpoint != null ? endpoint.getHostName() : null; + + delegate.upgrade(sessionLayer, endpoint, attachment, handshakeTimeout, + new FutureCallback() { + @Override + public void completed(final TransportSecurityLayer result) { + final List t = tags("ok", sni); + Timer.builder(mc.prefix + ".tls.handshake").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".tls.handshakes").tags(t).register(registry).increment(); + if (callback != null) { + callback.completed(result); + } + } + + @Override + public void failed(final Exception ex) { + final List t = tags("error", sni); + Timer.builder(mc.prefix + ".tls.handshake").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".tls.handshakes").tags(t).register(registry).increment(); + if (callback != null) { + callback.failed(ex); + } + } + + @Override + public void cancelled() { + final List t = tags("cancel", sni); + Timer.builder(mc.prefix + ".tls.handshake").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".tls.handshakes").tags(t).register(registry).increment(); + if (callback != null) { + callback.cancelled(); + } + } + }); + } + + + /** + * Records metrics while delegating to the classic upgrade path. + * + * @deprecated Implementations should prefer the async overload; this remains to fulfill the interface. + */ + @Deprecated + @Override + public boolean upgrade( + final TransportSecurityLayer sessionLayer, + final HttpHost host, + final SocketAddress localAddress, + final SocketAddress remoteAddress, + final Object attachment, + final Timeout handshakeTimeout) { + + final long t0 = System.nanoTime(); + final String sni = host != null ? host.getHostName() : null; + + try { + final boolean upgraded = delegate.upgrade( + sessionLayer, host, localAddress, remoteAddress, attachment, handshakeTimeout); + final List t = tags("ok", sni); + Timer.builder(mc.prefix + ".tls.handshake").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".tls.handshakes").tags(t).register(registry).increment(); + return upgraded; + } catch (final RuntimeException ex) { + final List t = tags("error", sni); + Timer.builder(mc.prefix + ".tls.handshake").tags(t).register(registry) + .record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); + Counter.builder(mc.prefix + ".tls.handshakes").tags(t).register(registry).increment(); + throw ex; + } + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/ObservationAsyncExecInterceptor.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/ObservationAsyncExecInterceptor.java new file mode 100644 index 0000000000..d291868df3 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/ObservationAsyncExecInterceptor.java @@ -0,0 +1,113 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.impl; + +import java.io.IOException; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.apache.hc.client5.http.async.AsyncExecCallback; +import org.apache.hc.client5.http.async.AsyncExecChain; +import org.apache.hc.client5.http.async.AsyncExecChainHandler; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.nio.AsyncDataConsumer; +import org.apache.hc.core5.http.nio.AsyncEntityProducer; + +/** + * Asynchronous execution interceptor that emits Micrometer {@link Observation}s + * around HTTP client exchanges. + *

+ * The observation is started before the request is executed and stopped when the + * exchange completes or fails. A simple sampling predicate from {@link ObservingOptions} + * can disable observations for given URIs without touching Micrometer configuration. + * + * @since 5.6 + */ +public final class ObservationAsyncExecInterceptor implements AsyncExecChainHandler { + + private final ObservationRegistry registry; + private final ObservingOptions opts; + + public ObservationAsyncExecInterceptor(final ObservationRegistry registry, + final ObservingOptions opts) { + this.registry = registry; + this.opts = opts; + } + + @Override + public void execute(final HttpRequest request, + final AsyncEntityProducer entityProducer, + final AsyncExecChain.Scope scope, + final AsyncExecChain chain, + final AsyncExecCallback asyncExecCallback) throws HttpException, IOException { + + if (!opts.spanSampling.test(request.getRequestUri())) { + chain.proceed(request, entityProducer, scope, asyncExecCallback); + return; + } + + final Observation observation = Observation + .createNotStarted("http.client.request", registry) + .contextualName(request.getMethod() + " " + request.getRequestUri()) + .lowCardinalityKeyValue("http.method", request.getMethod()) + .lowCardinalityKeyValue("net.peer.name", scope.route.getTargetHost().getHostName()) + .start(); + + final AsyncExecCallback wrappedCallback = new AsyncExecCallback() { + @Override + public AsyncDataConsumer handleResponse(final HttpResponse response, + final EntityDetails entityDetails) throws HttpException, IOException { + observation.lowCardinalityKeyValue("http.status_code", Integer.toString(response.getCode())); + return asyncExecCallback.handleResponse(response, entityDetails); + } + + @Override + public void handleInformationResponse(final HttpResponse response) throws HttpException, IOException { + asyncExecCallback.handleInformationResponse(response); + } + + @Override + public void completed() { + observation.stop(); + asyncExecCallback.completed(); + } + + @Override + public void failed(final Exception cause) { + observation.error(cause); + observation.stop(); + asyncExecCallback.failed(cause); + } + }; + + chain.proceed(request, entityProducer, scope, wrappedCallback); + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/ObservationClassicExecInterceptor.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/ObservationClassicExecInterceptor.java new file mode 100644 index 0000000000..705370ae88 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/ObservationClassicExecInterceptor.java @@ -0,0 +1,114 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.impl; + +import java.io.IOException; +import java.net.URISyntaxException; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.apache.hc.client5.http.classic.ExecChain; +import org.apache.hc.client5.http.classic.ExecChainHandler; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.util.Args; + +/** + * Classic (blocking) execution interceptor that emits Micrometer {@link Observation}s + * around HTTP client exchanges. + *

+ * The observation is started before the request is executed and stopped when the + * exchange completes or fails. A simple sampling predicate from {@link ObservingOptions} + * can disable observations for given URIs without touching Micrometer configuration. + * + * @since 5.6 + */ +public final class ObservationClassicExecInterceptor implements ExecChainHandler { + + private final ObservationRegistry registry; + private final ObservingOptions opts; + + public ObservationClassicExecInterceptor(final ObservationRegistry registry, + final ObservingOptions opts) { + this.registry = Args.notNull(registry, "observationRegistry"); + this.opts = opts != null ? opts : ObservingOptions.DEFAULT; + } + + @Override + public ClassicHttpResponse execute(final ClassicHttpRequest request, + final ExecChain.Scope scope, + final ExecChain chain) + throws IOException, HttpException { + + if (!opts.spanSampling.test(request.getRequestUri())) { + return chain.proceed(request, scope); + } + + final String method = request.getMethod(); + final String uriForName = safeUriForName(request); + final String peer = request.getAuthority().getHostName(); + + final Observation obs = Observation + .createNotStarted("http.client.request", registry) + .contextualName(method + " " + uriForName) + .lowCardinalityKeyValue("http.method", method) + .lowCardinalityKeyValue("net.peer.name", peer) + .start(); + + ClassicHttpResponse response = null; + Throwable error = null; + try { + response = chain.proceed(request, scope); + return response; + } catch (final Throwable t) { + error = t; + throw t; + } finally { + if (response != null) { + obs.lowCardinalityKeyValue("http.status_code", Integer.toString(response.getCode())); + } + if (opts.tagLevel == ObservingOptions.TagLevel.EXTENDED) { + obs.lowCardinalityKeyValue("http.scheme", scope.route.getTargetHost().getSchemeName()) + .lowCardinalityKeyValue("net.peer.name", scope.route.getTargetHost().getHostName()); + } + if (error != null) { + obs.error(error); + } + obs.stop(); + } + } + + private static String safeUriForName(final ClassicHttpRequest req) { + try { + return req.getUri().toString(); + } catch (final URISyntaxException e) { + return req.getRequestUri(); + } + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/package-info.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/package-info.java new file mode 100644 index 0000000000..050821dcfe --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/package-info.java @@ -0,0 +1,32 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Simple facade APIs for HttpClient based on the concept of + * an observation implementations interface. + */ +package org.apache.hc.client5.http.observation.impl; diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncIoByteCounterExec.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncIoByteCounterExec.java new file mode 100644 index 0000000000..a53bccbc3c --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncIoByteCounterExec.java @@ -0,0 +1,178 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.interceptors; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import org.apache.hc.client5.http.async.AsyncExecCallback; +import org.apache.hc.client5.http.async.AsyncExecChain; +import org.apache.hc.client5.http.async.AsyncExecChainHandler; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.nio.AsyncEntityProducer; +import org.apache.hc.core5.util.Args; + +/** + * Counts request / response payload bytes for asynchronous clients. + *

+ * Meters: + *

    + *
  • {@code <prefix>.request.bytes} (counter, baseUnit=bytes)
  • + *
  • {@code <prefix>.response.bytes} (counter, baseUnit=bytes)
  • + *
+ * Tags: {@code method}, {@code status}, and when {@link ObservingOptions.TagLevel#EXTENDED} + * also {@code protocol}, {@code target}. If {@link MetricConfig#perUriIo} is true, adds {@code uri}. + * Any {@link MetricConfig#commonTags} are appended. A custom tag mutator may be provided via + * {@code ObservingOptions.tagCustomizer}. + * + * @since 5.6 + */ +public final class AsyncIoByteCounterExec implements AsyncExecChainHandler { + + private final MeterRegistry meterRegistry; + private final ObservingOptions opts; + private final MetricConfig mc; + + private final Counter.Builder reqBuilder; + private final Counter.Builder respBuilder; + + public AsyncIoByteCounterExec(final MeterRegistry meterRegistry, + final ObservingOptions opts, + final MetricConfig mc) { + this.meterRegistry = Args.notNull(meterRegistry, "meterRegistry"); + this.opts = Args.notNull(opts, "observingOptions"); + this.mc = Args.notNull(mc, "metricConfig"); + + this.reqBuilder = Counter.builder(mc.prefix + ".request.bytes") + .description("HTTP request payload size") + .baseUnit("bytes"); + + this.respBuilder = Counter.builder(mc.prefix + ".response.bytes") + .description("HTTP response payload size") + .baseUnit("bytes"); + } + + @Override + public void execute(final HttpRequest request, + final AsyncEntityProducer entityProducer, + final AsyncExecChain.Scope scope, + final AsyncExecChain chain, + final AsyncExecCallback callback) + throws HttpException, IOException { + + if (!opts.spanSampling.test(request.getRequestUri())) { + chain.proceed(request, entityProducer, scope, callback); + return; + } + + final long reqBytes = entityProducer != null ? entityProducer.getContentLength() : -1L; + + final AtomicReference respRef = new AtomicReference<>(); + final AtomicLong respLen = new AtomicLong(-1L); + + final AsyncExecCallback wrapped = new AsyncExecCallback() { + + @Override + public org.apache.hc.core5.http.nio.AsyncDataConsumer handleResponse( + final HttpResponse response, final EntityDetails entityDetails) throws HttpException, IOException { + + respRef.set(response); + if (entityDetails != null) { + respLen.set(entityDetails.getContentLength()); + } + return callback.handleResponse(response, entityDetails); + } + + @Override + public void handleInformationResponse(final HttpResponse response) throws HttpException, IOException { + callback.handleInformationResponse(response); + } + + @Override + public void completed() { + record(); + callback.completed(); + } + + @Override + public void failed(final Exception cause) { + record(); + callback.failed(cause); + } + + private void record() { + final HttpResponse rsp = respRef.get(); + final int status = rsp != null ? rsp.getCode() : 599; + + final String protocol = scope.route.getTargetHost().getSchemeName(); + final String target = scope.route.getTargetHost().getHostName(); + final String uri = request.getRequestUri(); + + final List tags = buildTags(request.getMethod(), status, protocol, target, uri); + + if (reqBytes >= 0) { + reqBuilder.tags(tags).tags(mc.commonTags).register(meterRegistry).increment(reqBytes); + } + final long rb = respLen.get(); + if (rb >= 0) { + respBuilder.tags(tags).tags(mc.commonTags).register(meterRegistry).increment(rb); + } + } + }; + + chain.proceed(request, entityProducer, scope, wrapped); + } + + private List buildTags(final String method, + final int status, + final String protocol, + final String target, + final String uri) { + final List tags = new ArrayList<>(8); + tags.add(Tag.of("method", method)); + tags.add(Tag.of("status", Integer.toString(status))); + if (opts.tagLevel == ObservingOptions.TagLevel.EXTENDED) { + tags.add(Tag.of("protocol", protocol)); + tags.add(Tag.of("target", target)); + } + if (mc.perUriIo) { + tags.add(Tag.of("uri", uri)); + } + return opts.tagCustomizer.apply(tags, method, status, protocol, target, uri); + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncTimerExec.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncTimerExec.java new file mode 100644 index 0000000000..5bf970bcf8 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncTimerExec.java @@ -0,0 +1,183 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.interceptors; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import org.apache.hc.client5.http.async.AsyncExecCallback; +import org.apache.hc.client5.http.async.AsyncExecChain; +import org.apache.hc.client5.http.async.AsyncExecChainHandler; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.util.Args; + +/** + * Records request latency and response counters for asynchronous clients. + *

+ * Meters: + *

    + *
  • {@code <prefix>.request} (timer) — latency, uses {@link MetricConfig#slo} and {@link MetricConfig#percentiles}
  • + *
  • {@code <prefix>.response} (counter) — result count
  • + *
  • {@code <prefix>.inflight} (gauge, kind=async) — in-flight request count
  • + *
+ * Tags: {@code method}, {@code status}, and when {@link ObservingOptions.TagLevel#EXTENDED} + * also {@code protocol}, {@code target}. Any {@link MetricConfig#commonTags} are appended. + * + * @since 5.6 + */ +public final class AsyncTimerExec implements AsyncExecChainHandler { + + private final MeterRegistry registry; + private final ObservingOptions opts; + private final MetricConfig mc; + private final AtomicInteger inflight = new AtomicInteger(); + + public AsyncTimerExec(final MeterRegistry reg, final ObservingOptions opts, final MetricConfig mc) { + this.registry = Args.notNull(reg, "registry"); + this.opts = Args.notNull(opts, "options"); + this.mc = mc != null ? mc : MetricConfig.builder().build(); + + // Tag-aware guard: only register once per (name + tags) + if (registry.find(this.mc.prefix + ".inflight") + .tags("kind", "async") + .tags(this.mc.commonTags) + .gauge() == null) { + Gauge.builder(this.mc.prefix + ".inflight", inflight, AtomicInteger::doubleValue) + .tag("kind", "async") + .tags(this.mc.commonTags) + .register(registry); + } + } + + @Override + public void execute(final HttpRequest request, + final org.apache.hc.core5.http.nio.AsyncEntityProducer entityProducer, + final AsyncExecChain.Scope scope, + final AsyncExecChain chain, + final AsyncExecCallback callback) throws HttpException, IOException { + + if (!opts.spanSampling.test(request.getRequestUri())) { + chain.proceed(request, entityProducer, scope, callback); + return; + } + + inflight.incrementAndGet(); + final long start = System.nanoTime(); + final AtomicReference respRef = new AtomicReference(); + + final AsyncExecCallback wrapped = new AsyncExecCallback() { + @Override + public org.apache.hc.core5.http.nio.AsyncDataConsumer handleResponse( + final HttpResponse response, final EntityDetails entityDetails) throws HttpException, IOException { + respRef.set(response); + return callback.handleResponse(response, entityDetails); + } + + @Override + public void handleInformationResponse(final HttpResponse response) throws HttpException, IOException { + callback.handleInformationResponse(response); + } + + @Override + public void completed() { + record(); + callback.completed(); + } + + @Override + public void failed(final Exception cause) { + record(); + callback.failed(cause); + } + + private void record() { + try { + final long dur = System.nanoTime() - start; + final HttpResponse r = respRef.get(); + final int status = r != null ? r.getCode() : 599; + + final String protocol = scope.route.getTargetHost().getSchemeName(); + final String target = scope.route.getTargetHost().getHostName(); + final List tags = buildTags(request.getMethod(), status, protocol, target, request.getRequestUri()); + + Timer.Builder tb = Timer.builder(mc.prefix + ".request") + .tags(tags) + .tags(mc.commonTags); + + if (mc.slo != null) { + tb = tb.serviceLevelObjectives(mc.slo); + } + if (mc.percentiles != null && mc.percentiles.length > 0) { + tb = tb.publishPercentiles(mc.percentiles); + } + + tb.register(registry).record(dur, TimeUnit.NANOSECONDS); + + Counter.builder(mc.prefix + ".response") + .tags(tags) + .tags(mc.commonTags) + .register(registry) + .increment(); + } finally { + inflight.decrementAndGet(); + } + } + }; + + chain.proceed(request, entityProducer, scope, wrapped); + } + + private List buildTags(final String method, + final int status, + final String protocol, + final String target, + final String uri) { + final List tags = new ArrayList<>(6); + tags.add(Tag.of("method", method)); + tags.add(Tag.of("status", Integer.toString(status))); + if (opts.tagLevel == ObservingOptions.TagLevel.EXTENDED) { + tags.add(Tag.of("protocol", protocol)); + tags.add(Tag.of("target", target)); + } + // Note: async timer does not add "uri" even if perUriIo is true (that flag is for IO counters). + return opts.tagCustomizer.apply(tags, method, status, protocol, target, uri); + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/IoByteCounterExec.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/IoByteCounterExec.java new file mode 100644 index 0000000000..4bca10abf2 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/IoByteCounterExec.java @@ -0,0 +1,144 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.interceptors; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import org.apache.hc.client5.http.classic.ExecChain; +import org.apache.hc.client5.http.classic.ExecChainHandler; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.util.Args; + +/** + * Counts request / response payload bytes for classic clients. + *

+ * Meters: + *

    + *
  • {@code <prefix>.request.bytes} (counter, baseUnit=bytes)
  • + *
  • {@code <prefix>.response.bytes} (counter, baseUnit=bytes)
  • + *
+ * Tags: {@code method}, {@code status}, and when {@link ObservingOptions.TagLevel#EXTENDED} + * also {@code protocol}, {@code target}. If {@link MetricConfig#perUriIo} is true, adds {@code uri}. + * Any {@link MetricConfig#commonTags} are appended. A custom tag mutator may be provided via + * {@code ObservingOptions.tagCustomizer}. + * + * @since 5.6 + */ +public final class IoByteCounterExec implements ExecChainHandler { + + private final MeterRegistry meterRegistry; + private final ObservingOptions opts; + private final MetricConfig mc; + + private final Counter.Builder reqBuilder; + private final Counter.Builder respBuilder; + + public IoByteCounterExec(final MeterRegistry meterRegistry, + final ObservingOptions opts, + final MetricConfig mc) { + this.meterRegistry = Args.notNull(meterRegistry, "meterRegistry"); + this.opts = Args.notNull(opts, "observingOptions"); + this.mc = Args.notNull(mc, "metricConfig"); + + this.reqBuilder = Counter.builder(mc.prefix + ".request.bytes") + .baseUnit("bytes") + .description("HTTP request payload size"); + + this.respBuilder = Counter.builder(mc.prefix + ".response.bytes") + .baseUnit("bytes") + .description("HTTP response payload size"); + } + + @Override + public ClassicHttpResponse execute(final ClassicHttpRequest request, + final ExecChain.Scope scope, + final ExecChain chain) throws IOException, HttpException { + + if (!opts.spanSampling.test(request.getRequestUri())) { + return chain.proceed(request, scope); + } + + final long reqBytes = contentLength(request.getEntity()); + ClassicHttpResponse response = null; + try { + response = chain.proceed(request, scope); + return response; + } finally { + final long respBytes = contentLength(response != null ? response.getEntity() : null); + + final int status = response != null ? response.getCode() : 599; + final String protocol = scope.route.getTargetHost().getSchemeName(); + final String target = scope.route.getTargetHost().getHostName(); + final String uri = request.getRequestUri(); + + final List tags = buildTags(request.getMethod(), status, protocol, target, uri); + + if (reqBytes >= 0) { + reqBuilder.tags(tags).tags(mc.commonTags).register(meterRegistry).increment(reqBytes); + } + if (respBytes >= 0) { + respBuilder.tags(tags).tags(mc.commonTags).register(meterRegistry).increment(respBytes); + } + } + } + + private static long contentLength(final HttpEntity entity) { + if (entity == null) { + return -1L; + } + final long len = entity.getContentLength(); + return len >= 0 ? len : -1L; + } + + private List buildTags(final String method, + final int status, + final String protocol, + final String target, + final String uri) { + final List tags = new ArrayList<>(8); + tags.add(Tag.of("method", method)); + tags.add(Tag.of("status", Integer.toString(status))); + if (opts.tagLevel == ObservingOptions.TagLevel.EXTENDED) { + tags.add(Tag.of("protocol", protocol)); + tags.add(Tag.of("target", target)); + } + if (mc.perUriIo) { + tags.add(Tag.of("uri", uri)); + } + return opts.tagCustomizer.apply(tags, method, status, protocol, target, uri); + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/TimerExec.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/TimerExec.java new file mode 100644 index 0000000000..918486a773 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/TimerExec.java @@ -0,0 +1,156 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.interceptors; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import org.apache.hc.client5.http.classic.ExecChain; +import org.apache.hc.client5.http.classic.ExecChainHandler; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.util.Args; + +/** + * Classic (blocking) interceptor that records a Micrometer {@link Timer} + * per request and a {@link Counter} for response codes. Also tracks in-flight + * requests via a {@link Gauge} tagged as {@code kind=classic}. + *

+ * Meters: + *

    + *
  • {@code <prefix>.request} (timer) — latency, uses {@link MetricConfig#slo} and {@link MetricConfig#percentiles}
  • + *
  • {@code <prefix>.response} (counter) — result count
  • + *
  • {@code <prefix>.inflight} (gauge, kind=classic) — in-flight request count
  • + *
+ * Tags: {@code method}, {@code status}, and when {@link ObservingOptions.TagLevel#EXTENDED} + * also {@code protocol}, {@code target}. Any {@link MetricConfig#commonTags} are appended. + * + * @since 5.6 + */ +public final class TimerExec implements ExecChainHandler { + + private final MeterRegistry registry; + private final ObservingOptions cfg; + private final MetricConfig mc; + + private final Timer.Builder timerBuilder; + private final Counter.Builder counterBuilder; + + private final AtomicInteger inflight = new AtomicInteger(0); + + /** + * Back-compat: two-arg ctor. + */ + public TimerExec(final MeterRegistry reg, final ObservingOptions cfg) { + this(reg, cfg, null); + } + + /** + * Preferred: honors {@link MetricConfig}. + */ + public TimerExec(final MeterRegistry reg, final ObservingOptions cfg, final MetricConfig mc) { + this.registry = Args.notNull(reg, "registry"); + this.cfg = Args.notNull(cfg, "config"); + this.mc = mc != null ? mc : MetricConfig.builder().build(); + + final String base = this.mc.prefix + "."; + this.timerBuilder = Timer.builder(base + "request").tags(this.mc.commonTags); + if (this.mc.percentiles != null && this.mc.percentiles.length > 0) { + this.timerBuilder.publishPercentiles(this.mc.percentiles); + } + if (this.mc.slo != null) { + this.timerBuilder.serviceLevelObjectives(this.mc.slo); + } + + this.counterBuilder = Counter.builder(base + "response").tags(this.mc.commonTags); + + // Tag-aware guard: only register once per (name + tags) + if (registry.find(this.mc.prefix + ".inflight") + .tags("kind", "classic") + .tags(this.mc.commonTags) + .gauge() == null) { + Gauge.builder(this.mc.prefix + ".inflight", inflight, AtomicInteger::doubleValue) + .tag("kind", "classic") + .tags(this.mc.commonTags) + .register(registry); + } + } + + @Override + public ClassicHttpResponse execute(final ClassicHttpRequest request, + final ExecChain.Scope scope, + final ExecChain chain) + throws IOException, HttpException { + + if (!cfg.spanSampling.test(request.getRequestUri())) { + return chain.proceed(request, scope); // fast-path + } + + inflight.incrementAndGet(); + final long start = System.nanoTime(); + ClassicHttpResponse response = null; + try { + response = chain.proceed(request, scope); + return response; + } finally { + try { + final long durNanos = System.nanoTime() - start; + final int status = response != null ? response.getCode() : 599; + + final List tags = new ArrayList(4); + tags.add(Tag.of("method", request.getMethod())); + tags.add(Tag.of("status", Integer.toString(status))); + + if (cfg.tagLevel == ObservingOptions.TagLevel.EXTENDED) { + tags.add(Tag.of("protocol", scope.route.getTargetHost().getSchemeName())); + tags.add(Tag.of("target", scope.route.getTargetHost().getHostName())); + } + + timerBuilder.tags(tags) + .register(registry) + .record(durNanos, TimeUnit.NANOSECONDS); + + counterBuilder.tags(tags) + .register(registry) + .increment(); + } finally { + inflight.decrementAndGet(); + } + } + } +} diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/package-info.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/package-info.java new file mode 100644 index 0000000000..b6ab1a9d49 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/package-info.java @@ -0,0 +1,32 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Simple facade APIs for HttpClient based on the concept of + * an interceptor interface. + */ +package org.apache.hc.client5.http.observation.interceptors; diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/package-info.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/package-info.java new file mode 100644 index 0000000000..9ed98e6852 --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/package-info.java @@ -0,0 +1,32 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +/** + * Simple facade APIs for HttpClient based on the concept of + * a observation interface. + */ +package org.apache.hc.client5.http.observation; diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/HttpClientObservationSupportTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/HttpClientObservationSupportTest.java new file mode 100644 index 0000000000..025fc3aa6c --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/HttpClientObservationSupportTest.java @@ -0,0 +1,125 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.EnumSet; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.ObservationRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.io.CloseMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class HttpClientObservationSupportTest { + + private HttpServer server; + + @AfterEach + void shutDown() { + if (this.server != null) { + this.server.close(CloseMode.GRACEFUL); + } + } + + @Test + void basicIoAndPoolMetricsRecorded() throws Exception { + // Register handler FOR THE HOST WE'LL USE ("localhost") + server = ServerBootstrap.bootstrap() + .setListenerPort(0) + .register("localhost", "/get", (request, response, context) -> { + response.setCode(HttpStatus.SC_OK); + response.setEntity(new StringEntity("{\"ok\":true}", ContentType.APPLICATION_JSON)); + }) + .create(); + server.start(); + + final int port = server.getLocalPort(); + + final MeterRegistry meters = new SimpleMeterRegistry(); + final ObservationRegistry observations = ObservationRegistry.create(); + + final MetricConfig mc = MetricConfig.builder() + .prefix("it") + .percentiles(0.95, 0.99) + .build(); + + final HttpClientConnectionManager cm = + PoolingHttpClientConnectionManagerBuilder.create().build(); + + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of( + ObservingOptions.MetricSet.BASIC, + ObservingOptions.MetricSet.IO, + ObservingOptions.MetricSet.CONN_POOL)) + .tagLevel(ObservingOptions.TagLevel.LOW) + .build(); + + final HttpClientBuilder b = HttpClients.custom().setConnectionManager(cm); + HttpClientObservationSupport.enable(b, observations, meters, opts, mc); + + // IMPORTANT: scheme-first ctor + RELATIVE PATH to avoid 421 + final HttpHost target = new HttpHost("http", "localhost", port); + + try (final CloseableHttpClient client = b.build()) { + final ClassicHttpResponse resp = client.executeOpen( + target, + ClassicRequestBuilder.get("/get").build(), + null); + assertEquals(200, resp.getCode()); + resp.close(); + } finally { + server.stop(); + } + + // BASIC + assertNotNull(meters.find(mc.prefix + ".request").timer()); + assertNotNull(meters.find(mc.prefix + ".response").counter()); + // IO + assertNotNull(meters.find(mc.prefix + ".response.bytes").counter()); + // POOL + assertNotNull(meters.find(mc.prefix + ".pool.leased").gauge()); + assertNotNull(meters.find(mc.prefix + ".pool.available").gauge()); + assertNotNull(meters.find(mc.prefix + ".pool.pending").gauge()); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/MetricConfigTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/MetricConfigTest.java new file mode 100644 index 0000000000..e5e6655557 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/MetricConfigTest.java @@ -0,0 +1,64 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +class MetricConfigTest { + + @Test + void builderSetsAllFields() { + final MetricConfig mc = MetricConfig.builder() + .prefix("custom") + .slo(Duration.ofMillis(250)) + .percentiles(0.1) + .perUriIo(true) + .addCommonTag("app", "demo") + .build(); + + assertEquals("custom", mc.prefix); + assertEquals(Duration.ofMillis(250), mc.slo); + assertTrue(mc.perUriIo); + assertFalse(mc.commonTags.isEmpty()); + } + + @Test + void defaultsAreSane() { + final MetricConfig mc = MetricConfig.builder().build(); + assertEquals("http.client", mc.prefix); + assertNotNull(mc.slo); + assertNotNull(mc.commonTags); + // don’t assert on percentiles’ *type* (int vs double[]) here + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/ObservingOptionsTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/ObservingOptionsTest.java new file mode 100644 index 0000000000..dc04bce985 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/ObservingOptionsTest.java @@ -0,0 +1,70 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.micrometer.observation.ObservationPredicate; +import org.junit.jupiter.api.Test; + +class ObservingOptionsTest { + + @Test + void builderWiresFields() { + final AtomicBoolean predCalled = new AtomicBoolean(false); + final ObservationPredicate micrometerFilter = (name, ctx) -> { + predCalled.set(true); + return true; + }; + + final ObservingOptions opts = ObservingOptions.builder() + .metrics(ObservingOptions.allMetricSets()) + .tagLevel(ObservingOptions.TagLevel.EXTENDED) + .micrometerFilter(micrometerFilter) + .spanSampling(uri -> uri != null && uri.contains("httpbin")) + .build(); + + assertTrue(opts.metricSets.containsAll(EnumSet.allOf(ObservingOptions.MetricSet.class))); + assertEquals(ObservingOptions.TagLevel.EXTENDED, opts.tagLevel); + assertTrue(opts.micrometerFilter.test("x", new io.micrometer.observation.Observation.Context())); + assertTrue(predCalled.get()); + assertTrue(opts.spanSampling.test("https://httpbin.org/get")); + assertFalse(opts.spanSampling.test("https://example.org/")); + } + + @Test + void defaultIsBasicLow() { + final ObservingOptions d = ObservingOptions.DEFAULT; + assertTrue(d.metricSets.contains(ObservingOptions.MetricSet.BASIC)); + assertEquals(ObservingOptions.TagLevel.LOW, d.tagLevel); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersAsyncTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersAsyncTest.java new file mode 100644 index 0000000000..48a7c93b95 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersAsyncTest.java @@ -0,0 +1,66 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.binder; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.nio.AsyncClientConnectionManager; +import org.junit.jupiter.api.Test; + +class ConnPoolMetersAsyncTest { + + @Test + void registersGaugesWhenAsyncPoolPresent() throws Exception { + final MeterRegistry reg = new SimpleMeterRegistry(); + + final AsyncClientConnectionManager cm = PoolingAsyncClientConnectionManagerBuilder.create().build(); + final HttpAsyncClientBuilder b = HttpAsyncClients.custom().setConnectionManager(cm); + + ConnPoolMetersAsync.bindTo(b, reg); + + // build to finalize builder configuration + b.build().close(); + + assertNotNull(reg.find("http.client.pool.leased").gauge()); + assertNotNull(reg.find("http.client.pool.available").gauge()); + assertNotNull(reg.find("http.client.pool.pending").gauge()); + } + + @Test + void noExceptionIfNoAsyncPool() { + final MeterRegistry reg = new SimpleMeterRegistry(); + final HttpAsyncClientBuilder b = HttpAsyncClients.custom(); // no CM set + ConnPoolMetersAsync.bindTo(b, reg); + assertNull(reg.find("http.client.pool.leased").gauge()); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersTest.java new file mode 100644 index 0000000000..8d02226c50 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersTest.java @@ -0,0 +1,68 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.binder; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.junit.jupiter.api.Test; + +class ConnPoolMetersTest { + + @Test + void registersGaugesWhenPoolPresent() throws Exception { + final MeterRegistry reg = new SimpleMeterRegistry(); + + final HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create().build(); + final HttpClientBuilder b = HttpClients.custom().setConnectionManager(cm); + + ConnPoolMeters.bindTo(b, reg); + + // build to finalize builder configuration + b.build().close(); + + assertNotNull(reg.find("http.client.pool.leased").gauge()); + assertNotNull(reg.find("http.client.pool.available").gauge()); + assertNotNull(reg.find("http.client.pool.pending").gauge()); + } + + @Test + void noExceptionIfNoPool() { + final MeterRegistry reg = new SimpleMeterRegistry(); + final HttpClientBuilder b = HttpClients.custom(); // no CM set + // should not throw + ConnPoolMeters.bindTo(b, reg); + // and nothing registered + assertNull(reg.find("http.client.pool.leased").gauge()); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/AsyncMetricsDemo.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/AsyncMetricsDemo.java new file mode 100644 index 0000000000..9cb6101e00 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/AsyncMetricsDemo.java @@ -0,0 +1,103 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.example; + +import java.net.URI; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.search.Search; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.observation.HttpClientObservationSupport; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; + +public final class AsyncMetricsDemo { + + // Use delay endpoints so inflight is > 0 while requests are running + private static final List URLS = Arrays.asList( + URI.create("https://httpbin.org/delay/1"), + URI.create("https://httpbin.org/delay/1") + ); + + public static void main(final String[] args) throws Exception { + final PrometheusMeterRegistry reg = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + Metrics.addRegistry(reg); + + final ObservationRegistry obs = ObservationRegistry.create(); + + final MetricConfig mc = MetricConfig.builder().build(); + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.allOf(ObservingOptions.MetricSet.class)) + .tagLevel(ObservingOptions.TagLevel.EXTENDED) + .build(); + + final HttpAsyncClientBuilder b = HttpAsyncClients.custom(); + HttpClientObservationSupport.enable(b, obs, reg, opts, mc); + + final CloseableHttpAsyncClient client = b.build(); + client.start(); + + // Fire two requests concurrently + final Future f1 = client.execute(SimpleRequestBuilder.get(URLS.get(0)).build(), null); + final Future f2 = client.execute(SimpleRequestBuilder.get(URLS.get(1)).build(), null); + + // Briefly wait to ensure they are inflight + TimeUnit.MILLISECONDS.sleep(150); + + // Try to locate inflight gauge (value may be > 0 while requests are active) + final Gauge inflight = Search.in(reg).name("http.client.inflight").gauge(); + System.out.println("inflight gauge present? " + (inflight != null)); + if (inflight != null) { + System.out.println("inflight value : " + inflight.value()); + } + + // Wait for results + System.out.println("R1: " + f1.get().getCode()); + System.out.println("R2: " + f2.get().getCode()); + + client.close(); + + System.out.println("--- scrape ---"); + System.out.println(reg.scrape()); + } + + private AsyncMetricsDemo() { + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/ClassicWithMetricConfigDemo.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/ClassicWithMetricConfigDemo.java new file mode 100644 index 0000000000..ecf6ba3a73 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/ClassicWithMetricConfigDemo.java @@ -0,0 +1,87 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.example; + +import java.net.URI; +import java.time.Duration; +import java.util.EnumSet; + +import io.micrometer.core.instrument.Metrics; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.observation.HttpClientObservationSupport; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; + +public final class ClassicWithMetricConfigDemo { + + private static final URI URL = URI.create("https://httpbin.org/get"); + + public static void main(final String[] args) throws Exception { + // 1) meters (Prometheus so we can scrape) + final PrometheusMeterRegistry reg = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + Metrics.addRegistry(reg); + + // 2) observations (no tracer needed for this demo) + final ObservationRegistry obs = ObservationRegistry.create(); + + // 3) custom MetricConfig + final MetricConfig mc = MetricConfig.builder() + .prefix("hc") // changes metric names, e.g. hc_request_seconds + .slo(Duration.ofMillis(250)) // example SLO + .percentiles(2) // publish p90 & p99 (your code maps this) + .addCommonTag("app", "demo") + .build(); + + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.allOf(ObservingOptions.MetricSet.class)) + .tagLevel(ObservingOptions.TagLevel.EXTENDED) + .build(); + + // 4) client + enable metrics + final HttpClientBuilder b = HttpClients.custom(); + HttpClientObservationSupport.enable(b, obs, reg, opts, mc); + + try (final CloseableHttpClient client = b.build()) { + final ClassicHttpResponse rsp = client.executeOpen(null, ClassicRequestBuilder.get(URL).build(), null); + System.out.println("HTTP " + rsp.getCode()); + rsp.close(); + } + + System.out.println("--- scrape ---"); + System.out.println(reg.scrape()); + } + + private ClassicWithMetricConfigDemo() { + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/DnsMetricsDemo.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/DnsMetricsDemo.java new file mode 100644 index 0000000000..3712569d4d --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/DnsMetricsDemo.java @@ -0,0 +1,107 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.example; + +import java.util.EnumSet; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.core.instrument.Metrics; + +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.observation.HttpClientObservationSupport; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; + +import org.apache.hc.client5.http.observation.impl.MeteredDnsResolver; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.core5.http.HttpHost; + +public final class DnsMetricsDemo { + + public static void main(final String[] args) throws Exception { + // 1) Prometheus registry + final PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + Metrics.addRegistry(registry); + + // 2) Observation (no tracer here) + final ObservationRegistry observations = ObservationRegistry.create(); + + // 3) Metric knobs + final MetricConfig mc = MetricConfig.builder() + .prefix("demo") + .percentiles(1) + .build(); + + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of( + ObservingOptions.MetricSet.BASIC, + ObservingOptions.MetricSet.IO, + ObservingOptions.MetricSet.CONN_POOL, + ObservingOptions.MetricSet.DNS // <-- we’ll wrap DNS + )) + .tagLevel(ObservingOptions.TagLevel.EXTENDED) + .build(); + + // 4) Classic client + real DNS resolver wrapped with metrics + final MeteredDnsResolver meteredResolver = + new MeteredDnsResolver(SystemDefaultDnsResolver.INSTANCE, registry, mc, opts); + + final HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() + .setDnsResolver(meteredResolver) + .build(); + + final HttpClientBuilder builder = HttpClients.custom() + .setConnectionManager(cm); + + // record http timers/counters + pool gauges + HttpClientObservationSupport.enable(builder, observations, registry, opts, mc); + + try (final CloseableHttpClient client = builder.build()) { + // Use a target so Host header is correct and connection manager engages normally + final HttpHost target = new HttpHost("http", "httpbin.org", 80); + final ClassicHttpResponse rsp = client.executeOpen( + target, + ClassicRequestBuilder.get("/get").build(), + null); + System.out.println("[classic DNS] " + rsp.getCode()); + rsp.close(); + } + + System.out.println("\n--- Prometheus scrape (DNS demo) ---"); + System.out.println(registry.scrape()); + } + + private DnsMetricsDemo() { } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/PoolGaugesDemo.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/PoolGaugesDemo.java new file mode 100644 index 0000000000..ca4493b8e1 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/PoolGaugesDemo.java @@ -0,0 +1,86 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.example; + +import java.net.URI; +import java.util.EnumSet; + +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.search.Search; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.observation.HttpClientObservationSupport; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; + +public final class PoolGaugesDemo { + + private static final URI URL = URI.create("https://httpbin.org/get"); + + public static void main(final String[] args) throws Exception { + final PrometheusMeterRegistry reg = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + Metrics.addRegistry(reg); + + final ObservationRegistry obs = ObservationRegistry.create(); + + final MetricConfig mc = MetricConfig.builder().build(); + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of(ObservingOptions.MetricSet.BASIC, + ObservingOptions.MetricSet.CONN_POOL)) + .tagLevel(ObservingOptions.TagLevel.LOW) + .build(); + + // Ensure a pooling manager is used so pool gauges exist + final HttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); + final HttpClientBuilder b = HttpClients.custom().setConnectionManager(cm); + + HttpClientObservationSupport.enable(b, obs, reg, opts, mc); + + try (final CloseableHttpClient client = b.build()) { + final ClassicHttpResponse rsp = client.executeOpen(null, ClassicRequestBuilder.get(URL).build(), null); + rsp.close(); + } + + System.out.println("pool.leased present? " + (Search.in(reg).name("http.client.pool.leased").gauge() != null)); + System.out.println("pool.available present? " + (Search.in(reg).name("http.client.pool.available").gauge() != null)); + System.out.println("pool.pending present? " + (Search.in(reg).name("http.client.pool.pending").gauge() != null)); + + System.out.println("--- scrape ---"); + System.out.println(reg.scrape()); + } + + private PoolGaugesDemo() { + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/SpanSamplingDemo.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/SpanSamplingDemo.java new file mode 100644 index 0000000000..b56c71bcca --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/SpanSamplingDemo.java @@ -0,0 +1,90 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.example; + +import java.net.URI; +import java.util.EnumSet; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.observation.HttpClientObservationSupport; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; + +public final class SpanSamplingDemo { + + private static final URI URL_OK = URI.create("https://httpbin.org/get"); + private static final URI URL_SKIP = URI.create("https://httpbin.org/anything/deny"); + + public static void main(final String[] args) throws Exception { + final PrometheusMeterRegistry reg = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + Metrics.addRegistry(reg); + + final ObservationRegistry obs = ObservationRegistry.create(); + final MetricConfig mc = MetricConfig.builder().build(); + + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of(ObservingOptions.MetricSet.BASIC, ObservingOptions.MetricSet.IO)) + .tagLevel(ObservingOptions.TagLevel.LOW) + .spanSampling(uri -> { + // Skip any URI containing "/deny" + return uri == null || !uri.contains("/deny"); + }) + .build(); + + final HttpClientBuilder b = HttpClients.custom(); + HttpClientObservationSupport.enable(b, obs, reg, opts, mc); + + try (final CloseableHttpClient client = b.build()) { + final ClassicHttpResponse r1 = client.executeOpen(null, ClassicRequestBuilder.get(URL_OK).build(), null); + r1.close(); + final ClassicHttpResponse r2 = client.executeOpen(null, ClassicRequestBuilder.get(URL_SKIP).build(), null); + r2.close(); + } + + // Sum all response counters (only the first request should contribute) + double total = 0.0; + for (final Counter c : reg.find("http.client.response").counters()) { + total += c.count(); + } + + System.out.println("Total http.client.response count (expected ~1.0) = " + total); + System.out.println("--- scrape ---"); + System.out.println(reg.scrape()); + } + + private SpanSamplingDemo() { + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TagLevelDemo.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TagLevelDemo.java new file mode 100644 index 0000000000..cdc8af0176 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TagLevelDemo.java @@ -0,0 +1,96 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.example; + +import java.net.URI; +import java.util.EnumSet; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.observation.HttpClientObservationSupport; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; + +/** + * Demonstrates LOW vs EXTENDED tag levels without Prometheus tag-key conflicts. + * We use two separate registries and two different metric prefixes. + */ +public final class TagLevelDemo { + + private static final URI URL = URI.create("https://httpbin.org/get"); + + public static void main(final String[] args) throws Exception { + final ObservationRegistry observations = ObservationRegistry.create(); + + // --------- LOW tag level (uses prefix "hc_low") ---------- + final PrometheusMeterRegistry regLow = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + final MetricConfig mcLow = MetricConfig.builder() + .prefix("hc_low") // different name -> no clash + .build(); + final ObservingOptions low = ObservingOptions.builder() + .metrics(EnumSet.of(ObservingOptions.MetricSet.BASIC)) + .tagLevel(ObservingOptions.TagLevel.LOW) + .build(); + + final HttpClientBuilder b1 = HttpClients.custom(); + HttpClientObservationSupport.enable(b1, observations, regLow, low, mcLow); + try (final CloseableHttpClient c1 = b1.build()) { + final ClassicHttpResponse r1 = c1.executeOpen(null, ClassicRequestBuilder.get(URL).build(), null); + r1.close(); + } + System.out.println("--- LOW scrape ---"); + System.out.println(regLow.scrape()); + + // --------- EXTENDED tag level (uses prefix "hc_ext") ---------- + final PrometheusMeterRegistry regExt = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + final MetricConfig mcExt = MetricConfig.builder() + .prefix("hc_ext") // different name -> no clash + .build(); + final ObservingOptions ext = ObservingOptions.builder() + .metrics(EnumSet.of(ObservingOptions.MetricSet.BASIC)) + .tagLevel(ObservingOptions.TagLevel.EXTENDED) + .build(); + + final HttpClientBuilder b2 = HttpClients.custom(); + HttpClientObservationSupport.enable(b2, observations, regExt, ext, mcExt); + try (final CloseableHttpClient c2 = b2.build()) { + final ClassicHttpResponse r2 = c2.executeOpen(null, ClassicRequestBuilder.get(URL).build(), null); + r2.close(); + } + System.out.println("--- EXTENDED scrape ---"); + System.out.println(regExt.scrape()); + } + + private TagLevelDemo() { + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TlsMetricsDemo.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TlsMetricsDemo.java new file mode 100644 index 0000000000..9f81babcb8 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TlsMetricsDemo.java @@ -0,0 +1,109 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.example; + +import java.util.EnumSet; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.Metrics; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.observation.HttpClientObservationSupport; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.client5.http.observation.impl.MeteredTlsStrategy; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; + +public final class TlsMetricsDemo { + + public static void main(final String[] args) throws Exception { + // 1) Prometheus registry + final PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + Metrics.addRegistry(registry); + + // 2) Observation (plain – add tracing bridge if you want spans) + final ObservationRegistry observations = ObservationRegistry.create(); + + // 3) Metric knobs + final MetricConfig mc = MetricConfig.builder() + .prefix("demo") + .percentiles(2) // p90 + p99 + .build(); + + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of( + ObservingOptions.MetricSet.BASIC, + ObservingOptions.MetricSet.IO, + ObservingOptions.MetricSet.TLS // we will record TLS starts/failures + )) + .tagLevel(ObservingOptions.TagLevel.EXTENDED) + .build(); + + // 4) Build a CM with a metered TLS strategy, then give it to the async builder + final TlsStrategy realTls = ClientTlsStrategyBuilder.create().buildAsync(); + final TlsStrategy meteredTls = new MeteredTlsStrategy(realTls, registry, mc, opts); + + // TLS strategy goes on the *connection manager* (not on the builder) + final org.apache.hc.client5.http.nio.AsyncClientConnectionManager cm = + PoolingAsyncClientConnectionManagerBuilder.create() + .setTlsStrategy(meteredTls) + .build(); + + final HttpAsyncClientBuilder builder = HttpAsyncClients.custom() + .setConnectionManager(cm); + + // Enable HTTP metrics (timers/counters, IO, etc.) + HttpClientObservationSupport.enable(builder, observations, registry, opts, mc); + + // 5) Run a real HTTPS request + try (final CloseableHttpAsyncClient client = builder.build()) { + client.start(); + + final SimpleHttpRequest req = SimpleRequestBuilder.get("https://httpbin.org/get").build(); + final Future fut = client.execute(req, null); + final SimpleHttpResponse rsp = fut.get(30, TimeUnit.SECONDS); + + System.out.println("[async TLS] " + rsp.getCode()); + } + + System.out.println("\n--- Prometheus scrape (TLS demo) ---"); + System.out.println(registry.scrape()); + } + + private TlsMetricsDemo() { + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TracingAndMetricsDemo.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TracingAndMetricsDemo.java new file mode 100644 index 0000000000..2b00a98a9a --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TracingAndMetricsDemo.java @@ -0,0 +1,146 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.example; + +import java.net.URI; +import java.util.ArrayList; +import java.util.EnumSet; + +import io.micrometer.core.instrument.Metrics; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import io.micrometer.tracing.handler.DefaultTracingObservationHandler; +import io.micrometer.tracing.otel.bridge.OtelBaggageManager; +import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext; +import io.micrometer.tracing.otel.bridge.OtelTracer; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.observation.HttpClientObservationSupport; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.client5.http.observation.impl.ObservationClassicExecInterceptor; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; + +/** + * Single-file demo: classic client + tracing + metrics. + */ +public final class TracingAndMetricsDemo { + + private static final URI URL = URI.create("https://httpbin.org/get"); + + public static void main(final String[] args) throws Exception { + + /* ---------------------------------------------------------------- + * 1) Micrometer metrics bootstrap + * ---------------------------------------------------------------- */ + final PrometheusMeterRegistry meters = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + Metrics.addRegistry(meters); // make it the global one + + /* ---------------------------------------------------------------- + * 2) OpenTelemetry bootstrap (in-memory exporter so nothing is sent + * over the wire) + * ---------------------------------------------------------------- */ + final InMemorySpanExporter spans = InMemorySpanExporter.create(); + + final SdkTracerProvider provider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spans)) + .setResource(Resource.empty()) + .build(); + + final OpenTelemetrySdk otel = OpenTelemetrySdk.builder() + .setTracerProvider(provider) + .setPropagators(ContextPropagators.noop()) + .build(); + + final OtelCurrentTraceContext ctx = new OtelCurrentTraceContext(); + final OtelTracer tracer = new OtelTracer( + otel.getTracer("demo"), + ctx, + event -> { + }, // no-op event bus + new OtelBaggageManager(ctx, new ArrayList<>(), new ArrayList<>())); + + /* Micrometer ObservationRegistry that delegates to the tracer */ + final ObservationRegistry observations = ObservationRegistry.create(); + observations.observationConfig().observationHandler(new DefaultTracingObservationHandler(tracer)); + + /* ---------------------------------------------------------------- + * 3) Build classic client + * ---------------------------------------------------------------- */ + final HttpClientBuilder builder = HttpClients.custom(); + + final ObservingOptions obs = ObservingOptions.builder() + .metrics(EnumSet.allOf(ObservingOptions.MetricSet.class)) + .tagLevel(ObservingOptions.TagLevel.EXTENDED) + .build(); + + // (A) span interceptor FIRST + builder.addExecInterceptorFirst("span", new ObservationClassicExecInterceptor(observations, obs)); + + // (B) metric interceptors + HttpClientObservationSupport.enable( + builder, + observations, + meters, + obs); + + /* ---------------------------------------------------------------- + * 4) Run one request + * ---------------------------------------------------------------- */ + try (final CloseableHttpClient client = builder.build()) { + final ClassicHttpResponse rsp = client.executeOpen( + null, ClassicRequestBuilder.get(URL).build(), null); + System.out.println("[classic] " + rsp.getCode()); + rsp.close(); + } + + /* ---------------------------------------------------------------- + * 5) Inspect results + * ---------------------------------------------------------------- */ + final double responses = meters.find("http.client.response").counter().count(); + final double latencySamples = meters.find("http.client.request").timer().count(); + + System.out.println("responses = " + responses); + System.out.println("latencySamples = " + latencySamples); + + System.out.println("\n--- Exported span ---"); + System.out.println(spans.getFinishedSpanItems().get(0)); + + // scrape all metrics (optional) + System.out.println("\n--- Prometheus scrape ---"); + System.out.println(meters.scrape()); + } + +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/MeteredDnsResolverTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/MeteredDnsResolverTest.java new file mode 100644 index 0000000000..dd6ace376a --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/MeteredDnsResolverTest.java @@ -0,0 +1,113 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.impl; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.junit.jupiter.api.Test; + +class MeteredDnsResolverTest { + + private static final class FakeOkResolver implements DnsResolver { + @Override + public InetAddress[] resolve(final String host) throws UnknownHostException { + // No real DNS call: construct loopback address directly + return new InetAddress[]{InetAddress.getByAddress("localhost", new byte[]{127, 0, 0, 1})}; + } + + @Override + public String resolveCanonicalHostname(final String host) { + return "localhost.localdomain"; + } + } + + private static final class FakeFailResolver implements DnsResolver { + @Override + public InetAddress[] resolve(final String host) throws UnknownHostException { + throw new UnknownHostException(host); + } + + @Override + public String resolveCanonicalHostname(final String host) throws UnknownHostException { + throw new UnknownHostException(host); + } + } + + @Test + void recordsTimersAndCounters_okPaths() throws Exception { + final MeterRegistry reg = new SimpleMeterRegistry(); + final MetricConfig mc = MetricConfig.builder().prefix("t").build(); + final ObservingOptions opts = ObservingOptions.builder() + .tagLevel(ObservingOptions.TagLevel.LOW) + .build(); + + final MeteredDnsResolver r = new MeteredDnsResolver(new FakeOkResolver(), reg, mc, opts); + + // Exercise both methods + r.resolve("example.test"); + r.resolveCanonicalHostname("example.test"); + + // Timers and counters should have at least one measurement on OK path + assertTrue(reg.find("t.dns.resolve").timer().count() >= 1L); + assertTrue(reg.find("t.dns.resolutions").counter().count() >= 1.0d); + assertTrue(reg.find("t.dns.canonical").timer().count() >= 1L); + assertTrue(reg.find("t.dns.canonicals").counter().count() >= 1.0d); + } + + @Test + void recordsTimersAndCounters_errorPaths() { + final MeterRegistry reg = new SimpleMeterRegistry(); + final MetricConfig mc = MetricConfig.builder().prefix("t2").build(); + + final MeteredDnsResolver r = new MeteredDnsResolver(new FakeFailResolver(), reg, mc, ObservingOptions.DEFAULT); + + try { + r.resolve("boom.test"); + } catch (final Exception ignore) { + // expected + } + try { + r.resolveCanonicalHostname("boom.test"); + } catch (final Exception ignore) { + // expected + } + + // Even on error, we should have recorded time and incremented counters + assertTrue(reg.find("t2.dns.resolve").timer().count() >= 1L); + assertTrue(reg.find("t2.dns.resolutions").counter().count() >= 1.0d); + assertTrue(reg.find("t2.dns.canonical").timer().count() >= 1L); + assertTrue(reg.find("t2.dns.canonicals").counter().count() >= 1.0d); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/MeteredTlsStrategyTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/MeteredTlsStrategyTest.java new file mode 100644 index 0000000000..637f00c7ef --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/MeteredTlsStrategyTest.java @@ -0,0 +1,206 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.observation.impl; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.SocketAddress; + +import javax.net.ssl.SSLContext; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.ssl.SSLBufferMode; +import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer; +import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.Test; + +class MeteredTlsStrategyTest { + + private static final class DummyTSL implements TransportSecurityLayer { + // no-op + public void startTls(final Object attachment, final Timeout handshakeTimeout, final FutureCallback callback) { + } + + public void close() { + } + + public boolean isOpen() { + return true; + } + + public void shutdown() { + } + + public String getId() { + return "dummy"; + } + + @Override + public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode, final SSLSessionInitializer initializer, final SSLSessionVerifier verifier, final Timeout handshakeTimeout) throws UnsupportedOperationException { + + } + + @Override + public TlsDetails getTlsDetails() { + return null; + } + } + + private static final class NE implements NamedEndpoint { + private final String host; + private final int port; + + NE(final String host, final int port) { + this.host = host; + this.port = port; + } + + public String getHostName() { + return host; + } + + public int getPort() { + return port; + } + } + + private static final class OkTls implements TlsStrategy { + @Override + public void upgrade(final TransportSecurityLayer sessionLayer, + final NamedEndpoint endpoint, + final Object attachment, + final Timeout handshakeTimeout, + final FutureCallback callback) { + // immediately complete + if (callback != null) { + callback.completed(sessionLayer); + } + } + + @Deprecated + @Override + public boolean upgrade(final TransportSecurityLayer sessionLayer, + final HttpHost host, + final SocketAddress localAddress, + final SocketAddress remoteAddress, + final Object attachment, + final Timeout handshakeTimeout) { + return true; + } + } + + private static final class FailTls implements TlsStrategy { + @Override + public void upgrade(final TransportSecurityLayer sessionLayer, + final NamedEndpoint endpoint, + final Object attachment, + final Timeout handshakeTimeout, + final FutureCallback callback) { + if (callback != null) { + callback.failed(new RuntimeException("boom")); + } + } + + @Deprecated + @Override + public boolean upgrade(final TransportSecurityLayer sessionLayer, + final HttpHost host, + final SocketAddress localAddress, + final SocketAddress remoteAddress, + final Object attachment, + final Timeout handshakeTimeout) { + throw new RuntimeException("boom"); + } + } + + @Test + void recordsOkOutcome_newApi() { + final MeterRegistry reg = new SimpleMeterRegistry(); + final MetricConfig mc = MetricConfig.builder().prefix("tls").build(); + final MeteredTlsStrategy m = new MeteredTlsStrategy(new OkTls(), reg, mc, ObservingOptions.DEFAULT); + + final TransportSecurityLayer tsl = new DummyTSL(); + m.upgrade(tsl, new NE("sni.local", 443), null, Timeout.ofSeconds(5), new FutureCallback() { + public void completed(final TransportSecurityLayer result) { + } + + public void failed(final Exception ex) { + } + + public void cancelled() { + } + }); + + assertTrue(reg.find("tls.tls.handshake").timer().count() >= 1L); + assertTrue(reg.find("tls.tls.handshakes").counter().count() >= 1.0d); + } + + @Test + void recordsErrorOutcome_bothApis() { + final MeterRegistry reg = new SimpleMeterRegistry(); + final MetricConfig mc = MetricConfig.builder().prefix("tls2").build(); + final MeteredTlsStrategy m = new MeteredTlsStrategy(new FailTls(), reg, mc, ObservingOptions.DEFAULT); + + final TransportSecurityLayer tsl = new DummyTSL(); + // new API + try { + m.upgrade(tsl, new NE("sni.local", 443), null, Timeout.ofSeconds(5), new FutureCallback() { + public void completed(final TransportSecurityLayer result) { + } + + public void failed(final Exception ex) { + } + + public void cancelled() { + } + }); + } catch (final RuntimeException ignore) { + // delegate calls failed via callback, no throw expected + } + + // deprecated API + try { + m.upgrade(tsl, new HttpHost("https", "sni.local", 443), null, null, null, Timeout.ofSeconds(5)); + } catch (final RuntimeException ignore) { + // expected throw + } + + assertTrue(reg.find("tls2.tls.handshake").timer().count() >= 2L); // once per API path + assertTrue(reg.find("tls2.tls.handshakes").counter().count() >= 2.0d); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/ObservationAsyncExecInterceptorTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/ObservationAsyncExecInterceptorTest.java new file mode 100644 index 0000000000..cfaaef024e --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/ObservationAsyncExecInterceptorTest.java @@ -0,0 +1,133 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.EnumSet; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.io.CloseMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservationAsyncExecInterceptorTest { + + private HttpServer server; + + private static final class CountingHandler + implements io.micrometer.observation.ObservationHandler { + final AtomicInteger starts = new AtomicInteger(); + final AtomicInteger stops = new AtomicInteger(); + + @Override + public boolean supportsContext(final Observation.Context c) { + return true; + } + + @Override + public void onStart(final Observation.Context c) { + starts.incrementAndGet(); + } + + @Override + public void onStop(final Observation.Context c) { + stops.incrementAndGet(); + } + } + + @AfterEach + void tearDown() { + if (server != null) { + server.close(CloseMode.GRACEFUL); + } + } + + @Test + void emitsObservationAroundAsyncCall() throws Exception { + // 1) Bind handler to the *localhost* vhost to avoid 421 + server = ServerBootstrap.bootstrap() + .setListenerPort(0) + .register("localhost", "/get", (request, response, context) -> { + response.setCode(HttpStatus.SC_OK); + response.setEntity(new StringEntity("{\"ok\":true}", ContentType.APPLICATION_JSON)); + }) + .create(); + server.start(); + final int port = server.getLocalPort(); + + // 2) Observation registry + final ObservationRegistry reg = ObservationRegistry.create(); + final CountingHandler h = new CountingHandler(); + reg.observationConfig().observationHandler(h); + + // 3) Options: observation only + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.noneOf(ObservingOptions.MetricSet.class)) + .build(); + + // 4) Async client with interceptor + final HttpAsyncClientBuilder b = HttpAsyncClients.custom(); + b.addExecInterceptorFirst("span", new ObservationAsyncExecInterceptor(reg, opts)); + + final HttpHost target = new HttpHost("http", "localhost", port); + + try (final CloseableHttpAsyncClient c = b.build()) { + c.start(); + + // IMPORTANT: relative path + target bound (Host=localhost:) + final SimpleHttpRequest req = SimpleRequestBuilder.get() + .setHttpHost(target) + .setPath("/get") + .build(); + + final Future fut = c.execute(req, null); + final SimpleHttpResponse resp = fut.get(10, TimeUnit.SECONDS); + assertEquals(200, resp.getCode()); + } + + assertEquals(1, h.starts.get()); + assertEquals(1, h.stops.get()); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/ObservationClassicExecInterceptorTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/ObservationClassicExecInterceptorTest.java new file mode 100644 index 0000000000..4a7df7b839 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/ObservationClassicExecInterceptorTest.java @@ -0,0 +1,125 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.EnumSet; +import java.util.concurrent.atomic.AtomicInteger; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.io.CloseMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservationClassicExecInterceptorTest { + + private HttpServer server; + + private static final class CountingHandler implements io.micrometer.observation.ObservationHandler { + final AtomicInteger starts = new AtomicInteger(); + final AtomicInteger stops = new AtomicInteger(); + + @Override + public boolean supportsContext(final Observation.Context context) { + return true; + } + + @Override + public void onStart(final Observation.Context context) { + starts.incrementAndGet(); + } + + @Override + public void onStop(final Observation.Context context) { + stops.incrementAndGet(); + } + } + + @AfterEach + void tearDown() { + if (server != null) { + server.close(CloseMode.GRACEFUL); + } + } + + @Test + void emitsObservationAroundClassicCall() throws Exception { + // Start an in-process HTTP server and register handler for the exact host we’ll use: "localhost" + server = ServerBootstrap.bootstrap() + .setListenerPort(0) + .register("localhost", "/get", (request, response, context) -> { + response.setCode(HttpStatus.SC_OK); + response.setEntity(new StringEntity("{\"ok\":true}", ContentType.APPLICATION_JSON)); + }) + .create(); + server.start(); + final int port = server.getLocalPort(); + + // Micrometer ObservationRegistry with a counting handler + final ObservationRegistry reg = ObservationRegistry.create(); + final CountingHandler h = new CountingHandler(); + reg.observationConfig().observationHandler(h); + + // No metrics here; we only test observation start/stop + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.noneOf(ObservingOptions.MetricSet.class)) + .build(); + + // Build classic client with the observation interceptor FIRST + final HttpClientBuilder b = HttpClients.custom(); + b.addExecInterceptorFirst("span", new ObservationClassicExecInterceptor(reg, opts)); + + final HttpHost target = new HttpHost("http", "localhost", port); + + try (final CloseableHttpClient c = b.build()) { + final ClassicHttpResponse resp = c.executeOpen( + target, + ClassicRequestBuilder.get("/get").build(), + null); + assertEquals(200, resp.getCode()); + resp.close(); + } + + // Exactly one observation around the request + assertEquals(1, h.starts.get(), "observation should start once"); + assertEquals(1, h.stops.get(), "observation should stop once"); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncIoByteCounterExecTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncIoByteCounterExecTest.java new file mode 100644 index 0000000000..a01a4ddce0 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncIoByteCounterExecTest.java @@ -0,0 +1,112 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.interceptors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.io.CloseMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class AsyncIoByteCounterExecTest { + + private HttpServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.close(CloseMode.GRACEFUL); + } + } + + @Test + void countsAsyncRequestAndResponseBytes() throws Exception { + // Local classic server providing fixed-size response + server = ServerBootstrap.bootstrap() + .setListenerPort(0) + .register("localhost", "/post", (request, response, context) -> { + response.setCode(HttpStatus.SC_OK); + response.setEntity(new StringEntity("OK!", ContentType.TEXT_PLAIN)); // known size = 3 + }) + .create(); + server.start(); + final int port = server.getLocalPort(); + + final MeterRegistry meters = new SimpleMeterRegistry(); + final MetricConfig mc = MetricConfig.builder().prefix("test").perUriIo(true).build(); + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of(ObservingOptions.MetricSet.IO)) + .tagLevel(ObservingOptions.TagLevel.LOW) + .build(); + + final HttpAsyncClientBuilder b = HttpAsyncClients.custom(); + // Attach the async IO byte counter interceptor under test + b.addExecInterceptorFirst("io", new AsyncIoByteCounterExec(meters, opts, mc)); + + try (final CloseableHttpAsyncClient client = b.build()) { + client.start(); + + final String url = "http://localhost:" + port + "/post"; + final SimpleHttpRequest req = SimpleRequestBuilder.post(url) + .setBody("HELLO", ContentType.TEXT_PLAIN) // known request size = 5 + .build(); + + final Future fut = client.execute(req, null); + final SimpleHttpResponse rsp = fut.get(20, TimeUnit.SECONDS); + assertEquals(200, rsp.getCode()); + } finally { + server.stop(); + } + + assertNotNull(meters.find(mc.prefix + ".request.bytes").counter()); + assertTrue(meters.find(mc.prefix + ".request.bytes").counter().count() > 0.0); + + assertNotNull(meters.find(mc.prefix + ".response.bytes").counter()); + assertTrue(meters.find(mc.prefix + ".response.bytes").counter().count() > 0.0); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncTimerExecTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncTimerExecTest.java new file mode 100644 index 0000000000..8dfa57650c --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncTimerExecTest.java @@ -0,0 +1,110 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.interceptors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.io.CloseMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class AsyncTimerExecTest { + + private HttpServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.close(CloseMode.GRACEFUL); + } + } + + @Test + void recordsAsyncLatencyAndCounter() throws Exception { + // Local classic server (async client can talk to it) + server = ServerBootstrap.bootstrap() + .setListenerPort(0) + .register("localhost", "/get", (request, response, context) -> { + response.setCode(HttpStatus.SC_OK); + response.setEntity(new StringEntity("{\"ok\":true}", ContentType.APPLICATION_JSON)); + }) + .create(); + server.start(); + final int port = server.getLocalPort(); + + final MeterRegistry meters = new SimpleMeterRegistry(); + final MetricConfig mc = MetricConfig.builder().prefix("test").build(); + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of(ObservingOptions.MetricSet.BASIC)) + .tagLevel(ObservingOptions.TagLevel.LOW) + .build(); + + final HttpAsyncClientBuilder b = HttpAsyncClients.custom(); + // Attach the async timer interceptor under test + b.addExecInterceptorFirst("timer", new AsyncTimerExec(meters, opts, mc)); + + try (final CloseableHttpAsyncClient client = b.build()) { + client.start(); + + // Use absolute URI that matches the registered host to avoid 421 + final String url = "http://localhost:" + port + "/get"; + final SimpleHttpRequest req = SimpleRequestBuilder.get(url).build(); + final Future fut = client.execute(req, null); + final SimpleHttpResponse rsp = fut.get(20, TimeUnit.SECONDS); + + assertEquals(200, rsp.getCode()); + } finally { + server.stop(); + } + + assertNotNull(meters.find(mc.prefix + ".request").timer()); + assertTrue(meters.find(mc.prefix + ".request").timer().count() >= 1); + assertNotNull(meters.find(mc.prefix + ".response").counter()); + assertNotNull(meters.find(mc.prefix + ".inflight").gauge()); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/IoByteCounterExecTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/IoByteCounterExecTest.java new file mode 100644 index 0000000000..1280fe685b --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/IoByteCounterExecTest.java @@ -0,0 +1,112 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.interceptors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.io.CloseMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class IoByteCounterExecTest { + + private HttpServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.close(CloseMode.GRACEFUL); + } + } + + @Test + void countsRequestAndResponseBytes() throws Exception { + // Echo-like handler with known response size + server = ServerBootstrap.bootstrap() + .setListenerPort(0) + .register("localhost", "/echo", (request, response, context) -> { + response.setCode(HttpStatus.SC_OK); + // Fixed-size body so response length is known + response.setEntity(new StringEntity("ACK", ContentType.TEXT_PLAIN)); + }) + .create(); + server.start(); + final int port = server.getLocalPort(); + + final MeterRegistry meters = new SimpleMeterRegistry(); + final MetricConfig mc = MetricConfig.builder().prefix("test").perUriIo(true).build(); + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of(ObservingOptions.MetricSet.IO)) + .tagLevel(ObservingOptions.TagLevel.LOW) + .build(); + + final HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create().build(); + final HttpClientBuilder b = HttpClients.custom().setConnectionManager(cm); + + // Attach the IO byte counter interceptor under test + b.addExecInterceptorFirst("io", new IoByteCounterExec(meters, opts, mc)); + + final HttpHost target = new HttpHost("http", "localhost", port); + try (final CloseableHttpClient client = b.build()) { + // Send a known-size request body (so request.bytes can increment) + final StringEntity body = new StringEntity("HELLO", ContentType.TEXT_PLAIN); + final ClassicHttpResponse resp = client.executeOpen( + target, ClassicRequestBuilder.post("/echo").setEntity(body).build(), null); + assertEquals(200, resp.getCode()); + resp.close(); + } finally { + server.stop(); + } + + assertNotNull(meters.find(mc.prefix + ".request.bytes").counter()); + assertTrue(meters.find(mc.prefix + ".request.bytes").counter().count() > 0.0); + + assertNotNull(meters.find(mc.prefix + ".response.bytes").counter()); + assertTrue(meters.find(mc.prefix + ".response.bytes").counter().count() > 0.0); + } +} diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/TimerExecTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/TimerExecTest.java new file mode 100644 index 0000000000..97e7605769 --- /dev/null +++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/TimerExecTest.java @@ -0,0 +1,109 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.observation.interceptors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.observation.MetricConfig; +import org.apache.hc.client5.http.observation.ObservingOptions; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.io.CloseMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class TimerExecTest { + + private HttpServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.close(CloseMode.GRACEFUL); + } + } + + @Test + void recordsLatencyAndCounter() throws Exception { + // Local classic server + server = ServerBootstrap.bootstrap() + .setListenerPort(0) + .register("localhost", "/get", (request, response, context) -> { + response.setCode(HttpStatus.SC_OK); + response.setEntity(new StringEntity("{\"ok\":true}", ContentType.APPLICATION_JSON)); + }) + .create(); + server.start(); + final int port = server.getLocalPort(); + + final MeterRegistry meters = new SimpleMeterRegistry(); + final MetricConfig mc = MetricConfig.builder().prefix("test").build(); + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.of(ObservingOptions.MetricSet.BASIC)) + .tagLevel(ObservingOptions.TagLevel.LOW) + .build(); + + final HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create().build(); + final HttpClientBuilder b = HttpClients.custom().setConnectionManager(cm); + + // Attach the timer interceptor under test + b.addExecInterceptorFirst("timer", new TimerExec(meters, opts, mc)); + + final HttpHost target = new HttpHost("http", "localhost", port); + try (final CloseableHttpClient client = b.build()) { + final ClassicHttpResponse resp = client.executeOpen( + target, ClassicRequestBuilder.get("/get").build(), null); + assertEquals(200, resp.getCode()); + resp.close(); + } finally { + server.stop(); + } + + assertNotNull(meters.find(mc.prefix + ".request").timer()); + assertTrue(meters.find(mc.prefix + ".request").timer().count() >= 1); + assertNotNull(meters.find(mc.prefix + ".response").counter()); + // inflight gauge exists (value ends at 0) + assertNotNull(meters.find(mc.prefix + ".inflight").gauge()); + } +} diff --git a/httpclient5-observation/src/test/resources/ApacheLogo.png b/httpclient5-observation/src/test/resources/ApacheLogo.png new file mode 100644 index 0000000000..c6daa67bab Binary files /dev/null and b/httpclient5-observation/src/test/resources/ApacheLogo.png differ diff --git a/httpclient5-observation/src/test/resources/log4j2.xml b/httpclient5-observation/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..dff8a53814 --- /dev/null +++ b/httpclient5-observation/src/test/resources/log4j2.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java index bb73238e45..a8957b9951 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java @@ -877,6 +877,11 @@ protected Function contextAdaptor() { return HttpClientContext::castOrCreate; } + @Internal + public AsyncClientConnectionManager getConnManager() { + return connManager; + } + @SuppressWarnings("deprecated") public CloseableHttpAsyncClient build() { AsyncClientConnectionManager connManagerCopy = this.connManager; diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java index a9483d6132..d815b880b7 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java @@ -832,6 +832,11 @@ protected void addCloseable(final Closeable closeable) { closeables.add(closeable); } + @Internal + public HttpClientConnectionManager getConnManager() { + return connManager; + } + @Internal protected Function contextAdaptor() { return HttpClientContext::castOrCreate; diff --git a/pom.xml b/pom.xml index f748421420..6bc02c8ee5 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,10 @@ javax.net.ssl.SSLEngine,javax.net.ssl.SSLParameters,java.nio.ByteBuffer,java.nio.CharBuffer 1.27.1 1.5.7-4 + 1.15.2 + 1.5.2 + 1.52.0 + 1.26.2 @@ -216,11 +220,48 @@ zstd-jni ${zstd.jni.version} + + io.micrometer + micrometer-core + ${micrometer.version} + true + + + io.micrometer + micrometer-observation + ${micrometer.version} + true + + + io.micrometer + micrometer-registry-prometheus + ${micrometer.version} + true + + + io.micrometer + micrometer-tracing-bridge-otel + ${micrometer.tracing.version} + true + + + io.opentelemetry + opentelemetry-sdk + ${otel.version} + true + + + io.opentelemetry + opentelemetry-sdk-testing + ${otel.version} + test + httpclient5 + httpclient5-observation httpclient5-fluent httpclient5-cache httpclient5-testing