From 75690da6993bcf8ef561063afc159c35f95bf506 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Sun, 7 Jun 2026 19:05:33 +0200 Subject: [PATCH] HTTPCLIENT-2423: Propagate trace context in observation interceptors Expose outbound requests as Micrometer sender carriers for header injection. --- .../impl/HttpClientObservationContext.java | 46 ++++++++++++ .../impl/ObservationAsyncExecInterceptor.java | 23 ++++-- .../ObservationClassicExecInterceptor.java | 8 ++- .../ObservationAsyncExecInterceptorTest.java | 70 +++++++++++++++++++ ...ObservationClassicExecInterceptorTest.java | 65 +++++++++++++++++ 5 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/HttpClientObservationContext.java diff --git a/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/HttpClientObservationContext.java b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/HttpClientObservationContext.java new file mode 100644 index 0000000000..35b746f0ad --- /dev/null +++ b/httpclient5-observation/src/main/java/org/apache/hc/client5/http/observation/impl/HttpClientObservationContext.java @@ -0,0 +1,46 @@ +/* + * ==================================================================== + * 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 io.micrometer.observation.transport.RequestReplySenderContext; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; + +final class HttpClientObservationContext extends RequestReplySenderContext { + + HttpClientObservationContext(final HttpRequest request) { + super(HttpClientObservationContext::setHeader); + setCarrier(request); + } + + private static void setHeader(final HttpRequest request, final String name, final String value) { + if (request != null) { + request.setHeader(name, value); + } + } + +} \ No newline at end of file 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 index d291868df3..d4f442a406 100644 --- 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 @@ -40,6 +40,7 @@ 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; /** * Asynchronous execution interceptor that emits Micrometer {@link Observation}s @@ -58,8 +59,8 @@ public final class ObservationAsyncExecInterceptor implements AsyncExecChainHand public ObservationAsyncExecInterceptor(final ObservationRegistry registry, final ObservingOptions opts) { - this.registry = registry; - this.opts = opts; + this.registry = Args.notNull(registry, "observationRegistry"); + this.opts = opts != null ? opts : ObservingOptions.DEFAULT; } @Override @@ -74,17 +75,21 @@ public void execute(final HttpRequest request, return; } + final HttpClientObservationContext observationContext = new HttpClientObservationContext(request); + final Observation observation = Observation - .createNotStarted("http.client.request", registry) + .createNotStarted("http.client.request", () -> observationContext, 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 { + observationContext.setResponse(response); observation.lowCardinalityKeyValue("http.status_code", Integer.toString(response.getCode())); return asyncExecCallback.handleResponse(response, entityDetails); } @@ -106,8 +111,16 @@ public void failed(final Exception cause) { observation.stop(); asyncExecCallback.failed(cause); } + }; - chain.proceed(request, entityProducer, scope, wrappedCallback); + try { + chain.proceed(request, entityProducer, scope, wrappedCallback); + } catch (final IOException | HttpException | RuntimeException | Error ex) { + observation.error(ex); + observation.stop(); + throw ex; + } } -} + +} \ No newline at end of file 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 index 230a8fcc01..9f8f494307 100644 --- 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 @@ -74,8 +74,10 @@ public ClassicHttpResponse execute(final ClassicHttpRequest request, final String uriForName = safeUriForName(request); final String peer = scope.route.getTargetHost().getHostName(); + final HttpClientObservationContext observationContext = new HttpClientObservationContext(request); + final Observation obs = Observation - .createNotStarted("http.client.request", registry) + .createNotStarted("http.client.request", () -> observationContext, registry) .contextualName(method + " " + uriForName) .lowCardinalityKeyValue("http.method", method) .lowCardinalityKeyValue("net.peer.name", peer) @@ -91,6 +93,7 @@ public ClassicHttpResponse execute(final ClassicHttpRequest request, throw t; } finally { if (response != null) { + observationContext.setResponse(response); obs.lowCardinalityKeyValue("http.status_code", Integer.toString(response.getCode())); } if (opts.tagLevel == ObservingOptions.TagLevel.EXTENDED) { @@ -111,4 +114,5 @@ private static String safeUriForName(final ClassicHttpRequest req) { return req.getRequestUri(); } } -} + +} \ No newline at end of file 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 index 72a5b20abb..5b5a897aeb 100644 --- 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 @@ -33,6 +33,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; @@ -44,6 +45,7 @@ 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.Header; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.impl.bootstrap.HttpServer; @@ -57,6 +59,13 @@ class ObservationAsyncExecInterceptorTest { private HttpServer server; + private static final String TRACE_PARENT = "traceparent"; + + private static final String TRACE_PARENT_VALUE = + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + + private static final String STALE_TRACE_PARENT_VALUE = "stale"; + private static final class CountingHandler implements io.micrometer.observation.ObservationHandler { final AtomicInteger starts = new AtomicInteger(); @@ -132,4 +141,65 @@ void emitsObservationAroundAsyncCall() throws Exception { assertEquals(1, h.starts.get()); assertEquals(1, h.stops.get()); } + + @Test + void propagatesTraceContextAroundAsyncCall() throws Exception { + final AtomicReference receivedTraceParent = new AtomicReference<>(); + + server = ServerBootstrap.bootstrap() + .setLocalAddress(InetAddress.getLoopbackAddress()) + .setListenerPort(0) + .register("localhost", "/get", (request, response, context) -> { + final Header traceParent = request.getFirstHeader(TRACE_PARENT); + receivedTraceParent.set(traceParent != null ? traceParent.getValue() : null); + response.setCode(HttpStatus.SC_OK); + response.setEntity(new StringEntity("{\"ok\":true}", ContentType.APPLICATION_JSON)); + }) + .create(); + server.start(); + + final ObservationRegistry reg = ObservationRegistry.create(); + reg.observationConfig().observationHandler(new TracePropagationHandler()); + + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.noneOf(ObservingOptions.MetricSet.class)) + .build(); + + final HttpAsyncClientBuilder b = HttpAsyncClients.custom(); + b.addExecInterceptorFirst("span", new ObservationAsyncExecInterceptor(reg, opts)); + + final HttpHost target = new HttpHost("http", "localhost", server.getLocalPort()); + + try (final CloseableHttpAsyncClient c = b.build()) { + c.start(); + + final SimpleHttpRequest request = SimpleRequestBuilder.get() + .setHttpHost(target) + .setPath("/get") + .build(); + request.setHeader(TRACE_PARENT, STALE_TRACE_PARENT_VALUE); + + final Future future = c.execute(request, null); + final SimpleHttpResponse response = future.get(10, TimeUnit.SECONDS); + + assertEquals(HttpStatus.SC_OK, response.getCode()); + } + + assertEquals(TRACE_PARENT_VALUE, receivedTraceParent.get()); + } + + private static final class TracePropagationHandler implements io.micrometer.observation.ObservationHandler { + + @Override + public boolean supportsContext(final Observation.Context context) { + return context instanceof HttpClientObservationContext; + } + + @Override + public void onStart(final Observation.Context context) { + final HttpClientObservationContext senderContext = (HttpClientObservationContext) context; + senderContext.getSetter().set(senderContext.getCarrier(), TRACE_PARENT, TRACE_PARENT_VALUE); + } + + } } 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 index a69cb2b580..d1c9bcd9d9 100644 --- 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 @@ -31,6 +31,7 @@ import java.net.InetAddress; import java.util.EnumSet; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; @@ -38,8 +39,10 @@ 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.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.impl.bootstrap.HttpServer; @@ -54,6 +57,13 @@ class ObservationClassicExecInterceptorTest { private HttpServer server; + private static final String TRACE_PARENT = "traceparent"; + + private static final String TRACE_PARENT_VALUE = + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + + private static final String STALE_TRACE_PARENT_VALUE = "stale"; + private static final class CountingHandler implements io.micrometer.observation.ObservationHandler { final AtomicInteger starts = new AtomicInteger(); final AtomicInteger stops = new AtomicInteger(); @@ -124,4 +134,59 @@ void emitsObservationAroundClassicCall() throws Exception { assertEquals(1, h.starts.get(), "observation should start once"); assertEquals(1, h.stops.get(), "observation should stop once"); } + + @Test + void propagatesTraceContextAroundClassicCall() throws Exception { + final AtomicReference receivedTraceParent = new AtomicReference<>(); + + server = ServerBootstrap.bootstrap() + .setLocalAddress(InetAddress.getLoopbackAddress()) + .setListenerPort(0) + .register("localhost", "/get", (request, response, context) -> { + final Header traceParent = request.getFirstHeader(TRACE_PARENT); + receivedTraceParent.set(traceParent != null ? traceParent.getValue() : null); + response.setCode(HttpStatus.SC_OK); + response.setEntity(new StringEntity("{\"ok\":true}", ContentType.APPLICATION_JSON)); + }) + .create(); + server.start(); + + final ObservationRegistry reg = ObservationRegistry.create(); + reg.observationConfig().observationHandler(new TracePropagationHandler()); + + final ObservingOptions opts = ObservingOptions.builder() + .metrics(EnumSet.noneOf(ObservingOptions.MetricSet.class)) + .build(); + + final HttpClientBuilder b = HttpClients.custom(); + b.addExecInterceptorFirst("span", new ObservationClassicExecInterceptor(reg, opts)); + + final HttpHost target = new HttpHost("http", "localhost", server.getLocalPort()); + + try (final CloseableHttpClient c = b.build()) { + final ClassicHttpRequest request = ClassicRequestBuilder.get("/get").build(); + request.setHeader(TRACE_PARENT, STALE_TRACE_PARENT_VALUE); + + final ClassicHttpResponse response = c.executeOpen(target, request, null); + assertEquals(HttpStatus.SC_OK, response.getCode()); + response.close(); + } + + assertEquals(TRACE_PARENT_VALUE, receivedTraceParent.get()); + } + + private static final class TracePropagationHandler implements io.micrometer.observation.ObservationHandler { + + @Override + public boolean supportsContext(final Observation.Context context) { + return context instanceof HttpClientObservationContext; + } + + @Override + public void onStart(final Observation.Context context) { + final HttpClientObservationContext senderContext = (HttpClientObservationContext) context; + senderContext.getSetter().set(senderContext.getCarrier(), TRACE_PARENT, TRACE_PARENT_VALUE); + } + + } }