diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/HttpClientObservationSupport.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/HttpClientObservationSupport.java
index 6880f594b3..74f0cdcd88 100644
--- a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/HttpClientObservationSupport.java
+++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/HttpClientObservationSupport.java
@@ -43,8 +43,10 @@
import org.apache.hc.client5.http.observation.impl.ObservationClassicExecInterceptor;
import org.apache.hc.client5.http.observation.impl.MeteredDnsResolver;
import org.apache.hc.client5.http.observation.impl.MeteredTlsStrategy;
+import org.apache.hc.client5.http.observation.interceptors.AsyncContentLengthExec;
import org.apache.hc.client5.http.observation.interceptors.AsyncIoByteCounterExec;
import org.apache.hc.client5.http.observation.interceptors.AsyncTimerExec;
+import org.apache.hc.client5.http.observation.interceptors.ContentLengthExec;
import org.apache.hc.client5.http.observation.interceptors.IoByteCounterExec;
import org.apache.hc.client5.http.observation.interceptors.TimerExec;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
@@ -94,6 +96,8 @@
* that surrounds each execution with a start/stop span.
*
Metric interceptors according to {@link ObservingOptions.MetricSet}:
* BASIC (latency timer + response counter), IO (bytes in/out counters),
+ * CONTENT_LENGTH (declared request/response entity length distribution
+ * summaries, opt-in),
* and CONN_POOL (pool gauges; classic and async variants). Pool lease
* timing is available when using {@link #meteredConnectionManager} or
* {@link #meteredAsyncConnectionManager}.
@@ -118,6 +122,10 @@ public final class HttpClientObservationSupport {
* Internal ID for I/O byte counters interceptor.
*/
private static final String IO_ID = "metric-io";
+ /**
+ * Internal ID for content length distribution summaries interceptor.
+ */
+ private static final String CONTENT_LENGTH_ID = "metric-content-length";
/* ====================== Classic ====================== */
@@ -203,6 +211,9 @@ public static void enable(final HttpClientBuilder builder,
if (o.metricSets.contains(ObservingOptions.MetricSet.IO)) {
builder.addExecInterceptorFirst(IO_ID, new IoByteCounterExec(meterReg, o, config));
}
+ if (o.metricSets.contains(ObservingOptions.MetricSet.CONTENT_LENGTH)) {
+ builder.addExecInterceptorFirst(CONTENT_LENGTH_ID, new ContentLengthExec(meterReg, o, config));
+ }
if (o.metricSets.contains(ObservingOptions.MetricSet.CONN_POOL)) {
ConnPoolMeters.bindTo(builder, meterReg, config);
}
@@ -294,6 +305,9 @@ public static void enable(final CachingHttpClientBuilder builder,
if (o.metricSets.contains(ObservingOptions.MetricSet.IO)) {
builder.addExecInterceptorAfter(ChainElement.CACHING.name(), IO_ID, new IoByteCounterExec(meterReg, o, config));
}
+ if (o.metricSets.contains(ObservingOptions.MetricSet.CONTENT_LENGTH)) {
+ builder.addExecInterceptorAfter(ChainElement.CACHING.name(), CONTENT_LENGTH_ID, new ContentLengthExec(meterReg, o, config));
+ }
if (o.metricSets.contains(ObservingOptions.MetricSet.CONN_POOL)) {
ConnPoolMeters.bindTo(builder, meterReg, config);
}
@@ -381,6 +395,9 @@ public static void enable(final HttpAsyncClientBuilder builder,
if (o.metricSets.contains(ObservingOptions.MetricSet.IO)) {
builder.addExecInterceptorFirst(IO_ID, new AsyncIoByteCounterExec(meterReg, o, config));
}
+ if (o.metricSets.contains(ObservingOptions.MetricSet.CONTENT_LENGTH)) {
+ builder.addExecInterceptorFirst(CONTENT_LENGTH_ID, new AsyncContentLengthExec(meterReg, o, config));
+ }
if (o.metricSets.contains(ObservingOptions.MetricSet.CONN_POOL)) {
ConnPoolMetersAsync.bindTo(builder, meterReg, config);
}
@@ -471,6 +488,9 @@ public static void enable(final CachingHttpAsyncClientBuilder builder,
if (o.metricSets.contains(ObservingOptions.MetricSet.IO)) {
builder.addExecInterceptorAfter(ChainElement.CACHING.name(), IO_ID, new AsyncIoByteCounterExec(meterReg, o, config));
}
+ if (o.metricSets.contains(ObservingOptions.MetricSet.CONTENT_LENGTH)) {
+ builder.addExecInterceptorAfter(ChainElement.CACHING.name(), CONTENT_LENGTH_ID, new AsyncContentLengthExec(meterReg, o, config));
+ }
if (o.metricSets.contains(ObservingOptions.MetricSet.CONN_POOL)) {
ConnPoolMetersAsync.bindTo(builder, meterReg, config);
}
diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/ObservingOptions.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/ObservingOptions.java
index d6c3d6501e..b1e1b23646 100644
--- a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/ObservingOptions.java
+++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/ObservingOptions.java
@@ -48,8 +48,25 @@ public final class ObservingOptions {
* {@link org.apache.hc.client5.http.observation.HttpClientObservationSupport#meteredTlsStrategy}
* and {@link org.apache.hc.client5.http.observation.HttpClientObservationSupport#meteredDnsResolver}
* to wrap the underlying TLS strategy or DNS resolver.
+ *
+ * {@link MetricSet#CONTENT_LENGTH} records the declared request and response
+ * entity lengths as distribution summaries. See
+ * {@link org.apache.hc.client5.http.observation.interceptors.ContentLengthExec}
+ * for details.
*/
- public enum MetricSet { BASIC, IO, CONN_POOL, TLS, DNS }
+ public enum MetricSet {
+ BASIC,
+ IO,
+ CONN_POOL,
+ TLS,
+ DNS,
+ /**
+ * Distributions of the declared request and response entity lengths, in bytes.
+ *
+ * @since 5.7
+ */
+ CONTENT_LENGTH
+ }
/**
* How many tags each metric/trace should get.
diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncContentLengthExec.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncContentLengthExec.java
new file mode 100644
index 0000000000..7d66b87e78
--- /dev/null
+++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/AsyncContentLengthExec.java
@@ -0,0 +1,149 @@
+/*
+ * ====================================================================
+ * 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 io.micrometer.core.instrument.DistributionSummary;
+import io.micrometer.core.instrument.MeterRegistry;
+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.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Records the declared request / response entity lengths for
+ * asynchronous clients as {@link DistributionSummary distribution summaries}.
+ *
+ * Meters, tags and semantics are the same as for {@link ContentLengthExec}. The
+ * request length is taken from {@link AsyncEntityProducer#getContentLength()} and
+ * the response length from {@link EntityDetails#getContentLength()}. They are the
+ * declared entity lengths as the application sees them, not the number of bytes
+ * transferred, and they are uncompressed only if no content coding is visible to
+ * the application. Nothing is recorded if no response was received, for entities
+ * of unknown length (for example chunked) or for messages without an entity. The
+ * data stream is never wrapped or buffered.
+ *
+ * Over HTTP/2, HttpCore reports the response entity length as unknown even if
+ * a {@code Content-Length} header is present, so no response length is recorded
+ * for HTTP/2 exchanges.
+ *
+ * Enabled by {@link ObservingOptions.MetricSet#CONTENT_LENGTH}.
+ *
+ * @see ContentLengthExec
+ * @since 5.7
+ */
+public final class AsyncContentLengthExec implements AsyncExecChainHandler {
+
+ private final ObservingOptions opts;
+ private final ContentLengthRecorder recorder;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param meterRegistry meter registry to register meters with
+ * @param opts observation/metric options
+ * @param mc metric configuration
+ */
+ public AsyncContentLengthExec(final MeterRegistry meterRegistry,
+ final ObservingOptions opts,
+ final MetricConfig mc) {
+ Args.notNull(meterRegistry, "meterRegistry");
+ this.opts = Args.notNull(opts, "observingOptions");
+ Args.notNull(mc, "metricConfig");
+ this.recorder = new ContentLengthRecorder(meterRegistry, opts, mc);
+ }
+
+ @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 requestContentLength = entityProducer != null
+ ? ContentLengthRecorder.normalize(entityProducer.getContentLength())
+ : -1L;
+
+ final AsyncExecCallback wrapped = new AsyncExecCallback() {
+
+ private volatile HttpResponse response;
+ private volatile long responseContentLength = -1L;
+
+ @Override
+ public AsyncDataConsumer handleResponse(final HttpResponse response,
+ final EntityDetails entityDetails) throws HttpException, IOException {
+ this.response = response;
+ if (entityDetails != null) {
+ responseContentLength = ContentLengthRecorder.normalize(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 = response;
+ if (rsp != null) {
+ recorder.record(request.getMethod(), rsp.getCode(), scope.route.getTargetHost(),
+ request.getRequestUri(), requestContentLength, responseContentLength);
+ }
+ }
+ };
+
+ chain.proceed(request, entityProducer, scope, wrapped);
+ }
+
+}
diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthExec.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthExec.java
new file mode 100644
index 0000000000..da374021ea
--- /dev/null
+++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthExec.java
@@ -0,0 +1,128 @@
+/*
+ * ====================================================================
+ * 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 io.micrometer.core.instrument.DistributionSummary;
+import io.micrometer.core.instrument.MeterRegistry;
+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;
+
+/**
+ * Records the declared request / response entity lengths for classic
+ * clients as {@link DistributionSummary distribution summaries}.
+ *
+ * Meters:
+ *
+ * - {@code .request.content.length} (distribution summary, baseUnit=bytes)
+ * - {@code .response.content.length} (distribution summary, baseUnit=bytes)
+ *
+ *
+ * Tags are the same as for the request timer: {@code method}, {@code status},
+ * and when {@link ObservingOptions.TagLevel#EXTENDED} also {@code protocol} and
+ * {@code target}. {@link ObservingOptions#tagCustomizer} is applied and
+ * {@link MetricConfig#commonTags} are appended. {@link MetricConfig#percentiles}
+ * are published if configured.
+ *
+ * What is measured. The values are taken from {@link HttpEntity#getContentLength()}
+ * of the request and response entities as the application sees them: before the
+ * client applies any content coding to the request and after the client
+ * transparently decodes the response. They are the declared entity lengths, not
+ * the number of bytes transferred: they exclude headers and transfer framing,
+ * and they are recorded even if the body is not (fully) transmitted, for example
+ * when the server rejects a request before reading its body.
+ *
+ * The declared length is the uncompressed length only if no content coding is
+ * visible to the application. It is the encoded length if the application sends
+ * an entity that is already encoded (for example with its own
+ * {@code Content-Encoding: gzip}) or if content compression is disabled and the
+ * response is not decoded. When the client handles content coding, this metric
+ * is useful where limits apply to the decoded size, for example servers that
+ * reject requests whose decoded body exceeds a maximum content length.
+ *
+ * What is not measured. Nothing is recorded if no response was received
+ * (for example the connection could not be established), for an entity whose
+ * length is not known up front (for example {@code Transfer-Encoding: chunked}
+ * or a response the client decodes transparently), or for a message without
+ * an entity. Entities are never wrapped or buffered, so the instrumentation adds
+ * no I/O overhead.
+ *
+ * Enabled by {@link ObservingOptions.MetricSet#CONTENT_LENGTH}.
+ *
+ * @see AsyncContentLengthExec
+ * @since 5.7
+ */
+public final class ContentLengthExec implements ExecChainHandler {
+
+ private final ObservingOptions opts;
+ private final ContentLengthRecorder recorder;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param meterRegistry meter registry to register meters with
+ * @param opts observation/metric options
+ * @param mc metric configuration
+ */
+ public ContentLengthExec(final MeterRegistry meterRegistry,
+ final ObservingOptions opts,
+ final MetricConfig mc) {
+ Args.notNull(meterRegistry, "meterRegistry");
+ this.opts = Args.notNull(opts, "observingOptions");
+ Args.notNull(mc, "metricConfig");
+ this.recorder = new ContentLengthRecorder(meterRegistry, opts, mc);
+ }
+
+ @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 requestContentLength = contentLength(request.getEntity());
+ final ClassicHttpResponse response = chain.proceed(request, scope);
+ recorder.record(request.getMethod(), response.getCode(), scope.route.getTargetHost(),
+ request.getRequestUri(), requestContentLength, contentLength(response.getEntity()));
+ return response;
+ }
+
+ private static long contentLength(final HttpEntity entity) {
+ return entity != null ? ContentLengthRecorder.normalize(entity.getContentLength()) : -1L;
+ }
+
+}
diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthRecorder.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthRecorder.java
new file mode 100644
index 0000000000..dde03bf813
--- /dev/null
+++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthRecorder.java
@@ -0,0 +1,119 @@
+/*
+ * ====================================================================
+ * 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.util.ArrayList;
+import java.util.List;
+
+import io.micrometer.core.instrument.DistributionSummary;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Tag;
+import io.micrometer.core.instrument.binder.BaseUnits;
+import org.apache.hc.client5.http.observation.MetricConfig;
+import org.apache.hc.client5.http.observation.ObservingOptions;
+import org.apache.hc.core5.http.HttpHost;
+
+/**
+ * Records declared request / response entity lengths, shared by
+ * {@link ContentLengthExec} and {@link AsyncContentLengthExec}.
+ */
+final class ContentLengthRecorder {
+
+ static final String REQUEST_SUFFIX = ".request.content.length";
+ static final String RESPONSE_SUFFIX = ".response.content.length";
+
+ private final MeterRegistry meterRegistry;
+ private final ObservingOptions opts;
+ private final MetricConfig mc;
+ private final String requestName;
+ private final String responseName;
+
+ ContentLengthRecorder(final MeterRegistry meterRegistry,
+ final ObservingOptions opts,
+ final MetricConfig mc) {
+ this.meterRegistry = meterRegistry;
+ this.opts = opts;
+ this.mc = mc;
+ this.requestName = mc.prefix + REQUEST_SUFFIX;
+ this.responseName = mc.prefix + RESPONSE_SUFFIX;
+ }
+
+ /**
+ * Records the given lengths of an exchange that received a response.
+ * Negative lengths (unknown length or no entity) are skipped.
+ */
+ void record(final String method,
+ final int status,
+ final HttpHost targetHost,
+ final String uri,
+ final long requestContentLength,
+ final long responseContentLength) {
+ if (requestContentLength < 0 && responseContentLength < 0) {
+ return;
+ }
+ final List tags = buildTags(method, status, targetHost, uri);
+ if (requestContentLength >= 0) {
+ summary(requestName, "Declared HTTP client request entity length", tags).record(requestContentLength);
+ }
+ if (responseContentLength >= 0) {
+ summary(responseName, "Declared HTTP client response entity length", tags).record(responseContentLength);
+ }
+ }
+
+ private DistributionSummary summary(final String name, final String description, final List tags) {
+ final DistributionSummary.Builder builder = DistributionSummary.builder(name)
+ .description(description)
+ .baseUnit(BaseUnits.BYTES)
+ .tags(mc.commonTags)
+ .tags(tags);
+ if (mc.percentiles != null && mc.percentiles.length > 0) {
+ builder.publishPercentiles(mc.percentiles);
+ }
+ return builder.register(meterRegistry);
+ }
+
+ private List buildTags(final String method,
+ final int status,
+ final HttpHost targetHost,
+ final String uri) {
+ final String protocol = targetHost != null ? targetHost.getSchemeName() : null;
+ final String target = targetHost != null ? targetHost.getHostName() : null;
+ final List tags = new ArrayList<>(4);
+ tags.add(Tag.of("method", method));
+ tags.add(Tag.of("status", Integer.toString(status)));
+ if (opts.tagLevel == ObservingOptions.TagLevel.EXTENDED && targetHost != null) {
+ tags.add(Tag.of("protocol", protocol));
+ tags.add(Tag.of("target", target));
+ }
+ return opts.tagCustomizer.apply(tags, method, status, protocol, target, uri);
+ }
+
+ static long normalize(final long contentLength) {
+ return contentLength >= 0 ? contentLength : -1L;
+ }
+
+}
diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncContentLengthExecTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncContentLengthExecTest.java
new file mode 100644
index 0000000000..8fee976a89
--- /dev/null
+++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/AsyncContentLengthExecTest.java
@@ -0,0 +1,253 @@
+/*
+ * ====================================================================
+ * 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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.EnumSet;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import io.micrometer.core.instrument.DistributionSummary;
+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.async.methods.SimpleResponseConsumer;
+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;
+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.EntityUtils;
+import org.apache.hc.core5.http.io.entity.InputStreamEntity;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.nio.AsyncRequestProducer;
+import org.apache.hc.core5.http.nio.entity.StringAsyncEntityProducer;
+import org.apache.hc.core5.http.nio.support.BasicRequestProducer;
+import org.apache.hc.core5.io.CloseMode;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class AsyncContentLengthExecTest {
+
+ private static final String REQUEST = "test.request.content.length";
+ private static final String RESPONSE = "test.response.content.length";
+
+ private HttpServer server;
+ private String baseUrl;
+ private MeterRegistry meters;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ server = ServerBootstrap.bootstrap()
+ .setLocalAddress(InetAddress.getLoopbackAddress())
+ .setListenerPort(0)
+ .register("localhost", "/known", (request, response, context) -> {
+ EntityUtils.consume(request.getEntity());
+ response.setCode(HttpStatus.SC_OK);
+ response.setEntity(new StringEntity("OK!", ContentType.TEXT_PLAIN));
+ })
+ .register("localhost", "/chunked", (request, response, context) -> {
+ EntityUtils.consume(request.getEntity());
+ response.setCode(HttpStatus.SC_OK);
+ response.setEntity(new InputStreamEntity(
+ new ByteArrayInputStream("chunky".getBytes(StandardCharsets.US_ASCII)), -1,
+ ContentType.TEXT_PLAIN));
+ })
+ .register("localhost", "/empty", (request, response, context) ->
+ response.setCode(HttpStatus.SC_NO_CONTENT))
+ .register("localhost", "/error", (request, response, context) -> {
+ EntityUtils.consume(request.getEntity());
+ response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR);
+ response.setEntity(new StringEntity("Boom!!", ContentType.TEXT_PLAIN));
+ })
+ .create();
+ server.start();
+ baseUrl = "http://localhost:" + server.getLocalPort();
+ meters = new SimpleMeterRegistry();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.close(CloseMode.GRACEFUL);
+ }
+ }
+
+ private static ObservingOptions options() {
+ return ObservingOptions.builder()
+ .metrics(EnumSet.of(ObservingOptions.MetricSet.CONTENT_LENGTH))
+ .build();
+ }
+
+ private static MetricConfig config() {
+ return MetricConfig.builder().prefix("test").percentiles().build();
+ }
+
+ private SimpleHttpResponse execute(final AsyncRequestProducer producer) throws Exception {
+ final HttpAsyncClientBuilder builder = HttpAsyncClients.custom().disableAutomaticRetries();
+ builder.addExecInterceptorFirst("content-length", new AsyncContentLengthExec(meters, options(), config()));
+ try (final CloseableHttpAsyncClient client = builder.build()) {
+ client.start();
+ return client.execute(producer, SimpleResponseConsumer.create(), null).get(20, TimeUnit.SECONDS);
+ }
+ }
+
+ private SimpleHttpResponse execute(final SimpleHttpRequest request) throws Exception {
+ final HttpAsyncClientBuilder builder = HttpAsyncClients.custom().disableAutomaticRetries();
+ builder.addExecInterceptorFirst("content-length", new AsyncContentLengthExec(meters, options(), config()));
+ try (final CloseableHttpAsyncClient client = builder.build()) {
+ client.start();
+ return client.execute(request, null).get(20, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ void recordsKnownRequestAndResponseLengths() throws Exception {
+ final SimpleHttpResponse response = execute(SimpleRequestBuilder.post(baseUrl + "/known")
+ .setBody("HELLO", ContentType.TEXT_PLAIN)
+ .build());
+ assertEquals(200, response.getCode());
+
+ final DistributionSummary req = meters.find(REQUEST).tag("method", "POST").tag("status", "200").summary();
+ assertNotNull(req);
+ assertEquals(1, req.count());
+ assertEquals(5.0, req.totalAmount());
+ assertEquals("bytes", req.getId().getBaseUnit());
+
+ final DistributionSummary rsp = meters.find(RESPONSE).tag("method", "POST").tag("status", "200").summary();
+ assertNotNull(rsp);
+ assertEquals(1, rsp.count());
+ assertEquals(3.0, rsp.totalAmount());
+ }
+
+ @Test
+ void recordsNothingForChunkedBodies() throws Exception {
+ final SimpleHttpResponse response = execute(new BasicRequestProducer(
+ new BasicHttpRequest("POST", URI.create(baseUrl + "/chunked")),
+ new StringAsyncEntityProducer("HELLO", ContentType.TEXT_PLAIN)));
+ assertEquals(200, response.getCode());
+ assertEquals("chunky", response.getBodyText());
+
+ assertNull(meters.find(REQUEST).summary());
+ assertNull(meters.find(RESPONSE).summary());
+ }
+
+ @Test
+ void recordsNothingWithoutEntity() throws Exception {
+ final SimpleHttpResponse response = execute(SimpleRequestBuilder.get(baseUrl + "/empty").build());
+ assertEquals(204, response.getCode());
+
+ assertNull(meters.find(REQUEST).summary());
+ assertNull(meters.find(RESPONSE).summary());
+ }
+
+ @Test
+ void recordsResponseOnlyForGet() throws Exception {
+ execute(SimpleRequestBuilder.get(baseUrl + "/known").build());
+
+ assertNull(meters.find(REQUEST).summary());
+ final DistributionSummary rsp = meters.find(RESPONSE).tag("method", "GET").summary();
+ assertNotNull(rsp);
+ assertEquals(3.0, rsp.totalAmount());
+ }
+
+ @Test
+ void recordsErrorResponsesWithStatusTag() throws Exception {
+ final SimpleHttpResponse response = execute(SimpleRequestBuilder.post(baseUrl + "/error")
+ .setBody("HELLO", ContentType.TEXT_PLAIN)
+ .build());
+ assertEquals(500, response.getCode());
+
+ final DistributionSummary req = meters.find(REQUEST).tag("status", "500").summary();
+ assertNotNull(req);
+ assertEquals(5.0, req.totalAmount());
+ final DistributionSummary rsp = meters.find(RESPONSE).tag("status", "500").summary();
+ assertNotNull(rsp);
+ assertEquals(6.0, rsp.totalAmount());
+ }
+
+ @Test
+ void recordsNothingWhenNoResponseIsReceived() throws Exception {
+ final int closedPort;
+ try (final ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+ closedPort = socket.getLocalPort();
+ }
+
+ assertThrows(ExecutionException.class, () -> execute(
+ SimpleRequestBuilder.post("http://localhost:" + closedPort + "/known")
+ .setBody("HELLO", ContentType.TEXT_PLAIN)
+ .build()));
+
+ assertNull(meters.find(REQUEST).summary());
+ assertNull(meters.find(RESPONSE).summary());
+ }
+
+ @Test
+ void enabledOnlyViaContentLengthMetricSet() throws Exception {
+ final MeterRegistry defaultMeters = new SimpleMeterRegistry();
+ final HttpAsyncClientBuilder defaults = HttpAsyncClients.custom();
+ HttpClientObservationSupport.enable(defaults, null, defaultMeters, ObservingOptions.DEFAULT);
+ try (final CloseableHttpAsyncClient client = defaults.build()) {
+ client.start();
+ client.execute(SimpleRequestBuilder.post(baseUrl + "/known")
+ .setBody("HELLO", ContentType.TEXT_PLAIN)
+ .build(), null).get(20, TimeUnit.SECONDS);
+ }
+ assertNull(defaultMeters.find("http.client.request.content.length").summary());
+
+ final HttpAsyncClientBuilder optedIn = HttpAsyncClients.custom();
+ HttpClientObservationSupport.enable(optedIn, null, meters, options());
+ try (final CloseableHttpAsyncClient client = optedIn.build()) {
+ client.start();
+ client.execute(SimpleRequestBuilder.post(baseUrl + "/known")
+ .setBody("HELLO", ContentType.TEXT_PLAIN)
+ .build(), null).get(20, TimeUnit.SECONDS);
+ }
+ assertNotNull(meters.find("http.client.request.content.length").summary());
+ assertNotNull(meters.find("http.client.response.content.length").summary());
+ }
+
+}
diff --git a/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthExecTest.java b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthExecTest.java
new file mode 100644
index 0000000000..8a71873f86
--- /dev/null
+++ b/httpclient5-observation/src/test/java/org/apache/hc/client5/http/observation/interceptors/ContentLengthExecTest.java
@@ -0,0 +1,277 @@
+/*
+ * ====================================================================
+ * 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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.nio.charset.StandardCharsets;
+import java.util.EnumSet;
+
+import io.micrometer.core.instrument.DistributionSummary;
+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.observation.HttpClientObservationSupport;
+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.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.EntityUtils;
+import org.apache.hc.core5.http.io.entity.InputStreamEntity;
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ContentLengthExecTest {
+
+ private static final String REQUEST = "test.request.content.length";
+ private static final String RESPONSE = "test.response.content.length";
+
+ private HttpServer server;
+ private HttpHost target;
+ private MeterRegistry meters;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ server = ServerBootstrap.bootstrap()
+ .setLocalAddress(InetAddress.getLoopbackAddress())
+ .setListenerPort(0)
+ .register("localhost", "/known", (request, response, context) -> {
+ EntityUtils.consume(request.getEntity());
+ response.setCode(HttpStatus.SC_OK);
+ response.setEntity(new StringEntity("OK!", ContentType.TEXT_PLAIN));
+ })
+ .register("localhost", "/chunked", (request, response, context) -> {
+ EntityUtils.consume(request.getEntity());
+ response.setCode(HttpStatus.SC_OK);
+ response.setEntity(new InputStreamEntity(
+ new ByteArrayInputStream("chunky".getBytes(StandardCharsets.US_ASCII)), -1,
+ ContentType.TEXT_PLAIN));
+ })
+ .register("localhost", "/empty", (request, response, context) ->
+ response.setCode(HttpStatus.SC_NO_CONTENT))
+ .register("localhost", "/error", (request, response, context) -> {
+ EntityUtils.consume(request.getEntity());
+ response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR);
+ response.setEntity(new StringEntity("Boom!!", ContentType.TEXT_PLAIN));
+ })
+ .create();
+ server.start();
+ target = new HttpHost("http", "localhost", server.getLocalPort());
+ meters = new SimpleMeterRegistry();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.close(CloseMode.GRACEFUL);
+ }
+ }
+
+ private static ObservingOptions options(final ObservingOptions.TagLevel tagLevel) {
+ return ObservingOptions.builder()
+ .metrics(EnumSet.of(ObservingOptions.MetricSet.CONTENT_LENGTH))
+ .tagLevel(tagLevel)
+ .build();
+ }
+
+ private static MetricConfig config() {
+ return MetricConfig.builder().prefix("test").percentiles().build();
+ }
+
+ private void execute(final ObservingOptions opts, final MetricConfig mc,
+ final HttpHost host, final ClassicHttpRequest request) throws IOException {
+ final HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries();
+ builder.addExecInterceptorFirst("content-length", new ContentLengthExec(meters, opts, mc));
+ try (final CloseableHttpClient client = builder.build()) {
+ client.execute(host, request, response -> {
+ EntityUtils.consume(response.getEntity());
+ return null;
+ });
+ }
+ }
+
+ @Test
+ void recordsKnownRequestAndResponseLengths() throws Exception {
+ execute(options(ObservingOptions.TagLevel.LOW), config(), target,
+ ClassicRequestBuilder.post("/known")
+ .setEntity(new StringEntity("HELLO", ContentType.TEXT_PLAIN))
+ .build());
+
+ final DistributionSummary req = meters.find(REQUEST).tag("method", "POST").tag("status", "200").summary();
+ assertNotNull(req);
+ assertEquals(1, req.count());
+ assertEquals(5.0, req.totalAmount());
+ assertEquals("bytes", req.getId().getBaseUnit());
+
+ final DistributionSummary rsp = meters.find(RESPONSE).tag("method", "POST").tag("status", "200").summary();
+ assertNotNull(rsp);
+ assertEquals(1, rsp.count());
+ assertEquals(3.0, rsp.totalAmount());
+
+ assertNull(meters.find(REQUEST).tagKeys("protocol").summary());
+ assertNull(meters.find(REQUEST).tagKeys("uri").summary());
+ }
+
+ @Test
+ void recordsNothingForChunkedBodies() throws Exception {
+ execute(options(ObservingOptions.TagLevel.LOW), config(), target,
+ ClassicRequestBuilder.post("/chunked")
+ .setEntity(new InputStreamEntity(
+ new ByteArrayInputStream("HELLO".getBytes(StandardCharsets.US_ASCII)), -1,
+ ContentType.TEXT_PLAIN))
+ .build());
+
+ assertNull(meters.find(REQUEST).summary());
+ assertNull(meters.find(RESPONSE).summary());
+ }
+
+ @Test
+ void recordsNothingWithoutEntity() throws Exception {
+ execute(options(ObservingOptions.TagLevel.LOW), config(), target,
+ ClassicRequestBuilder.get("/empty").build());
+
+ assertNull(meters.find(REQUEST).summary());
+ assertNull(meters.find(RESPONSE).summary());
+ }
+
+ @Test
+ void recordsResponseOnlyForGet() throws Exception {
+ execute(options(ObservingOptions.TagLevel.LOW), config(), target,
+ ClassicRequestBuilder.get("/known").build());
+
+ assertNull(meters.find(REQUEST).summary());
+ final DistributionSummary rsp = meters.find(RESPONSE).tag("method", "GET").summary();
+ assertNotNull(rsp);
+ assertEquals(3.0, rsp.totalAmount());
+ }
+
+ @Test
+ void recordsErrorResponsesWithStatusTag() throws Exception {
+ execute(options(ObservingOptions.TagLevel.LOW), config(), target,
+ ClassicRequestBuilder.post("/error")
+ .setEntity(new StringEntity("HELLO", ContentType.TEXT_PLAIN))
+ .build());
+
+ final DistributionSummary req = meters.find(REQUEST).tag("status", "500").summary();
+ assertNotNull(req);
+ assertEquals(5.0, req.totalAmount());
+ final DistributionSummary rsp = meters.find(RESPONSE).tag("status", "500").summary();
+ assertNotNull(rsp);
+ assertEquals(6.0, rsp.totalAmount());
+ }
+
+ @Test
+ void recordsNothingWhenNoResponseIsReceived() throws Exception {
+ final int closedPort;
+ try (final ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+ closedPort = socket.getLocalPort();
+ }
+ final HttpHost unreachable = new HttpHost("http", "localhost", closedPort);
+
+ assertThrows(IOException.class, () -> execute(options(ObservingOptions.TagLevel.LOW), config(), unreachable,
+ ClassicRequestBuilder.post("/known")
+ .setEntity(new StringEntity("HELLO", ContentType.TEXT_PLAIN))
+ .build()));
+
+ assertNull(meters.find(REQUEST).summary());
+ assertNull(meters.find(RESPONSE).summary());
+ }
+
+ @Test
+ void extendedTagsAndCommonTags() throws Exception {
+ final MetricConfig mc = MetricConfig.builder().prefix("test").addCommonTag("app", "demo").build();
+ execute(options(ObservingOptions.TagLevel.EXTENDED), mc, target,
+ ClassicRequestBuilder.post("/known")
+ .setEntity(new StringEntity("HELLO", ContentType.TEXT_PLAIN))
+ .build());
+
+ final DistributionSummary req = meters.find(REQUEST)
+ .tag("protocol", "http")
+ .tag("target", "localhost")
+ .tag("app", "demo")
+ .summary();
+ assertNotNull(req);
+ assertEquals(5.0, req.totalAmount());
+ }
+
+ @Test
+ void skipsUnsampledRequests() throws Exception {
+ final ObservingOptions opts = ObservingOptions.builder()
+ .metrics(EnumSet.of(ObservingOptions.MetricSet.CONTENT_LENGTH))
+ .spanSampling(uri -> false)
+ .build();
+ execute(opts, config(), target,
+ ClassicRequestBuilder.post("/known")
+ .setEntity(new StringEntity("HELLO", ContentType.TEXT_PLAIN))
+ .build());
+
+ assertNull(meters.find(REQUEST).summary());
+ assertNull(meters.find(RESPONSE).summary());
+ }
+
+ @Test
+ void enabledOnlyViaContentLengthMetricSet() throws Exception {
+ final MeterRegistry defaultMeters = new SimpleMeterRegistry();
+ final HttpClientBuilder defaults = HttpClients.custom();
+ HttpClientObservationSupport.enable(defaults, null, defaultMeters, ObservingOptions.DEFAULT);
+ try (final CloseableHttpClient client = defaults.build()) {
+ client.execute(target, ClassicRequestBuilder.post("/known")
+ .setEntity(new StringEntity("HELLO", ContentType.TEXT_PLAIN))
+ .build(), response -> null);
+ }
+ assertNull(defaultMeters.find("http.client.request.content.length").summary());
+
+ final HttpClientBuilder optedIn = HttpClients.custom();
+ HttpClientObservationSupport.enable(optedIn, null, meters, options(ObservingOptions.TagLevel.LOW));
+ try (final CloseableHttpClient client = optedIn.build()) {
+ client.execute(target, ClassicRequestBuilder.post("/known")
+ .setEntity(new StringEntity("HELLO", ContentType.TEXT_PLAIN))
+ .build(), response -> null);
+ }
+ assertNotNull(meters.find("http.client.request.content.length").summary());
+ assertNotNull(meters.find("http.client.response.content.length").summary());
+ assertNull(meters.find("http.client.request.bytes").counter());
+ }
+
+}