From 11a8c04e14285ddea81c39aa00b9a9c38bdae091 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Mon, 4 Aug 2025 18:38:35 +0200 Subject: [PATCH] HTTPCLIENT-2387: Micrometer/OTel observations & metrics (5.6) Add httpclient5-observation with timers/counters for classic/async, IO bytes, pool gauges, DNS/TLS meters; Observation interceptors; MetricConfig & options; offline tests; optional deps; Javadoc @since 5.6; no-reflection pool binding. --- httpclient5-observation/pom.xml | 151 ++++++ .../HttpClientObservationSupport.java | 473 ++++++++++++++++++ .../http/observation/MetricConfig.java | 135 +++++ .../http/observation/ObservingOptions.java | 132 +++++ .../observation/binder/ConnPoolMeters.java | 133 +++++ .../binder/ConnPoolMetersAsync.java | 133 +++++ .../http/observation/binder/package-info.java | 32 ++ .../observation/impl/MeteredDnsResolver.java | 135 +++++ .../observation/impl/MeteredTlsStrategy.java | 202 ++++++++ .../impl/ObservationAsyncExecInterceptor.java | 113 +++++ .../ObservationClassicExecInterceptor.java | 114 +++++ .../http/observation/impl/package-info.java | 32 ++ .../interceptors/AsyncIoByteCounterExec.java | 178 +++++++ .../interceptors/AsyncTimerExec.java | 183 +++++++ .../interceptors/IoByteCounterExec.java | 144 ++++++ .../observation/interceptors/TimerExec.java | 156 ++++++ .../interceptors/package-info.java | 32 ++ .../http/observation/package-info.java | 32 ++ .../HttpClientObservationSupportTest.java | 125 +++++ .../http/observation/MetricConfigTest.java | 64 +++ .../observation/ObservingOptionsTest.java | 70 +++ .../binder/ConnPoolMetersAsyncTest.java | 66 +++ .../binder/ConnPoolMetersTest.java | 68 +++ .../observation/example/AsyncMetricsDemo.java | 103 ++++ .../example/ClassicWithMetricConfigDemo.java | 87 ++++ .../observation/example/DnsMetricsDemo.java | 107 ++++ .../observation/example/PoolGaugesDemo.java | 86 ++++ .../observation/example/SpanSamplingDemo.java | 90 ++++ .../observation/example/TagLevelDemo.java | 96 ++++ .../observation/example/TlsMetricsDemo.java | 109 ++++ .../example/TracingAndMetricsDemo.java | 146 ++++++ .../impl/MeteredDnsResolverTest.java | 113 +++++ .../impl/MeteredTlsStrategyTest.java | 206 ++++++++ .../ObservationAsyncExecInterceptorTest.java | 133 +++++ ...ObservationClassicExecInterceptorTest.java | 125 +++++ .../AsyncIoByteCounterExecTest.java | 112 +++++ .../interceptors/AsyncTimerExecTest.java | 110 ++++ .../interceptors/IoByteCounterExecTest.java | 112 +++++ .../interceptors/TimerExecTest.java | 109 ++++ .../src/test/resources/ApacheLogo.png | Bin 0 -> 34983 bytes .../src/test/resources/log4j2.xml | 29 ++ .../impl/async/HttpAsyncClientBuilder.java | 5 + .../http/impl/classic/HttpClientBuilder.java | 5 + pom.xml | 41 ++ 44 files changed, 4827 insertions(+) create mode 100644 httpclient5-observation/pom.xml create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/HttpClientObservationSupport.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/MetricConfig.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/ObservingOptions.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/ConnPoolMeters.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersAsync.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/binder/package-info.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/MeteredDnsResolver.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/MeteredTlsStrategy.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/ObservationAsyncExecInterceptor.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/ObservationClassicExecInterceptor.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/package-info.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncIoByteCounterExec.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncTimerExec.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/IoByteCounterExec.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/TimerExec.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/package-info.java create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/package-info.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/HttpClientObservationSupportTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/MetricConfigTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/ObservingOptionsTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersAsyncTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/binder/ConnPoolMetersTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/AsyncMetricsDemo.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/ClassicWithMetricConfigDemo.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/DnsMetricsDemo.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/PoolGaugesDemo.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/SpanSamplingDemo.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TagLevelDemo.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TlsMetricsDemo.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/example/TracingAndMetricsDemo.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/MeteredDnsResolverTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/MeteredTlsStrategyTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/ObservationAsyncExecInterceptorTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/impl/ObservationClassicExecInterceptorTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncIoByteCounterExecTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncTimerExecTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/IoByteCounterExecTest.java create mode 100644 httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/TimerExecTest.java create mode 100644 httpclient5-observation/src/test/resources/ApacheLogo.png create mode 100644 httpclient5-observation/src/test/resources/log4j2.xml 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 0000000000000000000000000000000000000000..c6daa67babc6960a452eb70918c1a67b939d29fe GIT binary patch literal 34983 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 Hu0mjf(+B3t literal 0 HcmV?d00001 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