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