Skip to content

Fix HttpClient 5.x callback span handling - #832

Open
Ayush0612005 wants to merge 3 commits into
apache:mainfrom
Ayush0612005:fix/httpclient5-future-callback-span
Open

Ayush0612005 wants to merge 3 commits into
apache:mainfrom
Ayush0612005:fix/httpclient5-future-callback-span

Conversation

@Ayush0612005

@Ayush0612005 Ayush0612005 commented Sep 21, 2026

Copy link
Copy Markdown

Fix HttpClient 5.x callback span handling (#14097)

  • Add regression tests for the caller-thread callback scenario.
  • Explain why the caller's active span was previously stopped.
  • Rework the async span lifecycle so the request span is finished by reference instead of relying on the current thread's active span.

The HttpClient 5.x plugin previously called ContextManager.stopSpan() from
FutureCallbackWrapper without verifying that the active span belonged to the
HTTP request.

With HttpAsyncClients.classic(...), the response body can be consumed on the
caller/business thread and the FutureCallback can therefore execute on that
same thread. In that case, the callback could incorrectly stop the caller's
active span.

The fix moves request ownership into an AsyncExitSpan shared by the async
request producer, response consumer, and callback lifecycle.

The request producer wrapper receives the concrete HttpRequest through
RequestChannel.sendRequest(...), creates the HTTP exit span in the caller
context, injects the propagation headers into the request, and detaches the
span for asynchronous completion.

The response consumer and callback finish the retained request span by
reference using asyncFinish(), so they do not stop whichever span happens to
be active on their current thread.

The reactor-thread poll() instrumentation used by the previous implementation
has been removed so request spans are not left on the I/O reactor thread's
ThreadLocal stack while multiple requests are in flight.

Regression coverage verifies that callback completion, failure, and
cancellation do not incorrectly stop the caller's active span.

  • Closes #14097.
  • Updates the CHANGES log.

@wu-sheng wu-sheng added bug Something isn't working plugin labels Sep 22, 2026
@wu-sheng wu-sheng added this to the 9.8.0 milestone Sep 22, 2026

@wu-sheng wu-sheng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this. The diagnosis is right: with HttpAsyncClients.classic(...), ClassicToAsyncResponseConsumer.fireComplete() runs the result callback on the caller thread when the body is read to EOF, so FutureCallbackWrapper pops the caller's span. But the fix introduces a regression and still leaves some cases broken. I checked everything below with unit probes and against a real client: the scenario app with the agent and the mock collector, on httpclient 5.0 and 5.5.2.

Issues in this PR

1. Regression: overlapping requests on one I/O reactor thread leave its context open forever

IOSessionImplPollInterceptor creates the local and exit spans in poll() and leaves both on the reactor thread's ThreadLocal span stack until the response arrives. A reactor thread serves many connections (one thread per CPU by default), so another request is often polled before the first response comes back:

poll(A)       [localA, exitA]
poll(B)       [localA, exitA, localB, exitB]   <- B is pushed into A's segment
response(A)   stopSpan() pops exitB            <- A's status lands on B's exit span

AsyncResponseConsumerWrapper.consumeResponse still pops whatever span is on top. The new OwnedSpans checks only stop a span when it is the one on top, so once the wrong span has been popped they never match again, and [localA, exitA] is never stopped.

Real client, httpclient 5.0, one reactor thread (IOReactorConfig.setIoThreadCount(1)), 4 overlapping requests answered out of order, 2 rounds:

Plugin I/O side
before this PR All 4 requests nested into one segment as a local, exit, local, exit… chain with 4 refs. The structure is wrong, but the segment is reported.
this PR The I/O segment is never reported. Round 2's spans (#9#15) nest into round 1's never-finished segment, so the downstream entry segments reference a parent segment that never arrives.

Any interleaving on a reactor thread follows the same pattern, for example HTTP/2 streams. I only tested HTTP/1.1 connections sharing one reactor thread.

2. The "is it the active span" check matches across threads when both contexts are ignored

IgnoredTracerContext.createLocalSpan() and activeSpan() return the same static NOOP_SPAN. So owned == ContextManager.activeSpan() in OwnedSpans is true on any thread whose context is ignored. Both sides are ignored when the OAP is disconnected (keep_tracing=false, the default) or when sampling drops both. The attribute is never removed, so the callback on the caller thread still ends the caller's ignored context. A unit probe showed the caller context closed after completed(), which is the #14097 symptom. The impact is small because no traced data is lost, but the next ContextManager.stopSpan() on that thread throws an NPE, which is logged as an intercept failure.

3. The local span now ends when the response headers arrive

Before, it ended at completed(), so it covered the body transfer. Now no span covers the time spent receiving the body.

4. The stored span is never removed from the HttpContext

When users reuse an HttpClientContext, it holds a reference to the finished span until the next request.

Other cases found (they predate this PR, but matter for the issue)

  • Null HttpContext on 5.4+. From 5.4 on, CloseableHttpAsyncClient#execute(SimpleHttpRequest, FutureCallback) passes a null context: 5.0–5.2.3 pass HttpClientContext.create(), and 5.4–5.6 pass null. The classic facade does the same for client.execute(request, handler). HttpAsyncClientDoExecuteInterceptor then throws an NPE on context.setAttribute(...), which is logged, and no snapshot is stored. In the exact repro from #14097, no HTTP span is created at all. The only effect of the plugin is FutureCallbackWrapper popping the caller's span. So the null-context test in this PR is the case that matters for the issue.
  • On 5.4+, the request is not in the context when poll() runs. HttpAsyncMainClientExec calls clientContext.setRequest(request) inside produceRequest, which the I/O layer calls after poll(). httpcore 5.3 also keeps the request in a field, so getAttribute(HTTP_REQUEST) returns null. The current code reads the request after createLocalSpan, so the resulting NPE leaves that span on the reactor thread. That path is only reachable when the caller passes its own context. So on 5.4+ the async plugin produces no I/O-side spans.
  • informationResponse (1xx) stops the exit span, so the final status code lands on the local span.
  • AsyncResponseConsumerWrapper.failed also pops whatever span is on top of the current thread.
  • httpclient-5.x-scenario only runs 5.0 and 5.1, so none of the 5.4+ behavior, the classic facade, or the interleaving case is covered by CI.

Suggested fix: async spans, finished by reference

Don't keep the spans on the reactor thread's stack; finish them by reference with the async-span API, as asynchttpclient-2.x-plugin does:

  • HttpAsyncClientDoExecuteInterceptor (caller thread): only when the caller context is active, create a small per-request AsyncRequestSpans object holding the snapshot. Store it in the HttpContext and pass it to AsyncResponseConsumerWrapper. If the context is null, set HttpClientContext.create() as the argument; castOrCreate (5.4+) and adapt (5.0) both keep the same instance. Stop wrapping the FutureCallback.
  • IOSessionImplPollInterceptor (reactor thread):
    1. Create the local and exit spans as before and inject the headers.
    2. Call exit.prepareForAsync() then stopSpan(exit), and local.prepareForAsync() then stopSpan(local). The order matters because prepareForAsync() requires the span to be the active one.
    3. Hand both spans to AsyncRequestSpans. The reactor thread's stack is empty again when poll() returns.
  • AsyncResponseConsumerWrapper:
    • consumeResponse: set the status tag, then exit.asyncFinish(). If there is no body, also local.asyncFinish().
    • streamEnd: local.asyncFinish() at the last body byte.
    • failed: mark both spans as errors and finish them.
    • releaseResources: fallback, finish whatever is still open.
    • informationResponse: no span work.
  • Each span sits in an AtomicReference and is finished at most once (getAndSet(null)), from whichever thread gets there first. asyncFinish() does nothing on NoopSpan, and there are no "is it on top" checks left, so issue 2 goes away too.
  • Remove FutureCallbackWrapper. Nothing that runs on the caller thread touches spans any more.
  • In poll(), read the request with HttpCoreContext#getRequest(), which has the same signature in 5.0 and 5.3, and read it before creating any span. When it's null, skip; don't throw after createLocalSpan.

Durations: the exit span runs from the request being sent to the response headers, as before. The local span runs from poll to the last body byte, which matches what completed() gave in the normal async path.

Verified results

Case before this PR this PR suggested fix
5.0, 4 overlapping requests on 1 reactor thread, 2 rounds 1 segment, all 4 nested segment never reported, round 2 nested into it one segment per request (local + exit, CrossThread ref to the caller, durations 429/125/328/74 ms for 400/100/300/50 ms server delays, downstream refs correct)
5.0, caller's own sync exit span after the async calls stays in the caller segment stays in the caller segment stays in the caller segment
5.5.2, HttpAsyncClients.classic(...), sync exit span after reading the body not re-run (reported in #14097) not run stays in the caller segment: the caller's context survives
unit tests: normal flow, overlapping requests, callback on the caller thread, failure after poll, response with no body, double release, null context, untraced caller 8/8 pass, plus checkstyle

Cases that still have issues with the suggested fix

  • 5.4+: still no I/O-side spans, because the request isn't available in poll() (see above). Supporting 5.4+ needs a different hook, for example where produceRequest runs. That is a separate follow-up, and 5.4+ is outside the documented 5.0/5.1 range. The caller-side fix for #14097 does work on 5.5.2.
  • An ignored caller snapshot is still continued. The I/O side then starts a new trace, which may be sampled. Only storing the snapshot when snapshot.isValid() would stop that; I left it out to keep the change focused.
  • Scenario coverage: it would help to add a single-reactor concurrent endpoint to httpclient-5.x-scenario (see the probe below), plus a 5.5+ version for the classic facade once the 5.4+ hook exists.
Suggested patch (main code, against main @ caf5d0a, replaces this PR's changes)
diff --git a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/AsyncRequestSpans.java b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/AsyncRequestSpans.java
new file mode 100644
index 0000000000..c196d3856c
--- /dev/null
+++ b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/AsyncRequestSpans.java
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ *
+ */
+
+package org.apache.skywalking.apm.plugin.httpclient.v5;
+
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.skywalking.apm.agent.core.context.ContextSnapshot;
+import org.apache.skywalking.apm.agent.core.context.tag.Tags;
+import org.apache.skywalking.apm.agent.core.context.trace.AbstractSpan;
+
+/**
+ * The tracing state of one async request. It is created on the caller thread when the request is submitted, reaches
+ * the I/O reactor thread through the request's {@code HttpContext}, and is held by the response consumer wrapper.
+ * <p>
+ * The spans are created on the reactor thread when the request is polled, and are stopped there right away in async
+ * mode. The reactor thread serves many connections, so the spans of one request must not stay on its span stack
+ * while other requests are processed. They are finished by {@link AbstractSpan#asyncFinish()} from whichever thread
+ * ends the exchange, each at most once.
+ */
+public class AsyncRequestSpans {
+
+    private final ContextSnapshot snapshot;
+    private final AtomicReference<AbstractSpan> localSpan = new AtomicReference<>();
+    private final AtomicReference<AbstractSpan> exitSpan = new AtomicReference<>();
+
+    public AsyncRequestSpans(ContextSnapshot snapshot) {
+        this.snapshot = snapshot;
+    }
+
+    public ContextSnapshot getSnapshot() {
+        return snapshot;
+    }
+
+    /**
+     * @param localSpan the local span, already stopped on the reactor thread after {@link AbstractSpan#prepareForAsync()}
+     * @param exitSpan  the exit span, already stopped on the reactor thread after {@link AbstractSpan#prepareForAsync()}
+     */
+    public void start(AbstractSpan localSpan, AbstractSpan exitSpan) {
+        this.localSpan.set(localSpan);
+        this.exitSpan.set(exitSpan);
+    }
+
+    /**
+     * The response head arrived, the exit span ends here.
+     */
+    public void onResponse(int statusCode) {
+        AbstractSpan span = exitSpan.getAndSet(null);
+        if (span != null) {
+            Tags.HTTP_RESPONSE_STATUS_CODE.set(span, statusCode);
+            if (statusCode >= 400) {
+                span.errorOccurred();
+            }
+            span.asyncFinish();
+        }
+    }
+
+    /**
+     * The whole response has been received, or the exchange was released.
+     */
+    public void onComplete() {
+        finish(exitSpan, null);
+        finish(localSpan, null);
+    }
+
+    public void onFailure(Exception cause) {
+        finish(exitSpan, cause);
+        finish(localSpan, cause);
+    }
+
+    private static void finish(AtomicReference<AbstractSpan> spanRef, Exception cause) {
+        AbstractSpan span = spanRef.getAndSet(null);
+        if (span != null) {
+            if (cause != null) {
+                span.errorOccurred().log(cause);
+            }
+            span.asyncFinish();
+        }
+    }
+}
diff --git a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/Constants.java b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/Constants.java
index 2497ca8cbc..3496565adf 100644
--- a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/Constants.java
+++ b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/Constants.java
@@ -19,5 +19,5 @@ package org.apache.skywalking.apm.plugin.httpclient.v5;
 
 public class Constants {
 
-    public static String SKYWALKING_CONTEXT_SNAPSHOT = "skywalking-context-snapshot";
+    public static final String SKYWALKING_ASYNC_REQUEST_SPANS = "skywalking-async-request-spans";
 }
diff --git a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/HttpAsyncClientDoExecuteInterceptor.java b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/HttpAsyncClientDoExecuteInterceptor.java
index 68267fcc6f..278742b3c6 100644
--- a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/HttpAsyncClientDoExecuteInterceptor.java
+++ b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/HttpAsyncClientDoExecuteInterceptor.java
@@ -18,7 +18,7 @@
 
 package org.apache.skywalking.apm.plugin.httpclient.v5;
 
-import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
 import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
 import org.apache.hc.core5.http.protocol.HttpContext;
 import org.apache.skywalking.apm.agent.core.context.ContextManager;
@@ -26,7 +26,6 @@ import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.EnhancedI
 import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstanceMethodsAroundInterceptor;
 import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.MethodInterceptResult;
 import org.apache.skywalking.apm.plugin.httpclient.v5.wrapper.AsyncResponseConsumerWrapper;
-import org.apache.skywalking.apm.plugin.httpclient.v5.wrapper.FutureCallbackWrapper;
 
 import java.lang.reflect.Method;
 
@@ -35,14 +34,19 @@ public class HttpAsyncClientDoExecuteInterceptor implements InstanceMethodsAroun
     @Override
     public void beforeMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
             MethodInterceptResult result) throws Throwable {
-        AsyncResponseConsumer consumer = (AsyncResponseConsumer) allArguments[2];
+        if (!ContextManager.isActive()) {
+            return;
+        }
         HttpContext context = (HttpContext) allArguments[4];
-        FutureCallback callback = (FutureCallback) allArguments[5];
-        allArguments[2] = new AsyncResponseConsumerWrapper(consumer);
-        allArguments[5] = new FutureCallbackWrapper(callback);
-        if (ContextManager.isActive()) {
-            context.setAttribute(Constants.SKYWALKING_CONTEXT_SNAPSHOT, ContextManager.capture());
+        if (context == null) {
+            // Since 5.4 the convenience execute methods pass no context, doExecute creates one by
+            // HttpClientContext#castOrCreate, which keeps a given HttpClientContext instance.
+            context = HttpClientContext.create();
+            allArguments[4] = context;
         }
+        AsyncRequestSpans spans = new AsyncRequestSpans(ContextManager.capture());
+        context.setAttribute(Constants.SKYWALKING_ASYNC_REQUEST_SPANS, spans);
+        allArguments[2] = new AsyncResponseConsumerWrapper<>((AsyncResponseConsumer<?>) allArguments[2], spans);
     }
 
     @Override
diff --git a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/IOSessionImplPollInterceptor.java b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/IOSessionImplPollInterceptor.java
index fc8ef190d4..3afc00f679 100644
--- a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/IOSessionImplPollInterceptor.java
+++ b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/IOSessionImplPollInterceptor.java
@@ -18,15 +18,14 @@
 
 package org.apache.skywalking.apm.plugin.httpclient.v5;
 
-import org.apache.hc.client5.http.protocol.HttpClientContext;
-import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.HttpRequest;
 import org.apache.hc.core5.http.nio.command.RequestExecutionCommand;
 import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
 import org.apache.hc.core5.reactor.Command;
 import org.apache.skywalking.apm.agent.core.context.CarrierItem;
 import org.apache.skywalking.apm.agent.core.context.ContextCarrier;
 import org.apache.skywalking.apm.agent.core.context.ContextManager;
-import org.apache.skywalking.apm.agent.core.context.ContextSnapshot;
 import org.apache.skywalking.apm.agent.core.context.tag.Tags;
 import org.apache.skywalking.apm.agent.core.context.trace.AbstractSpan;
 import org.apache.skywalking.apm.agent.core.context.trace.SpanLayer;
@@ -54,26 +53,33 @@ public class IOSessionImplPollInterceptor implements InstanceMethodsAroundInterc
             return ret;
         }
         HttpContext httpContext = ((RequestExecutionCommand) command).getContext();
-        ContextSnapshot snapshot = (ContextSnapshot) httpContext.getAttribute(Constants.SKYWALKING_CONTEXT_SNAPSHOT);
-        if (snapshot == null) {
+        AsyncRequestSpans spans = (AsyncRequestSpans) httpContext.getAttribute(Constants.SKYWALKING_ASYNC_REQUEST_SPANS);
+        if (spans == null) {
             return ret;
         }
-        httpContext.removeAttribute(Constants.SKYWALKING_CONTEXT_SNAPSHOT);
+        httpContext.removeAttribute(Constants.SKYWALKING_ASYNC_REQUEST_SPANS);
+        // Since httpcore 5.3 the request is a field of HttpCoreContext, not an attribute any more.
+        HttpRequest request = httpContext instanceof HttpCoreContext
+                ? ((HttpCoreContext) httpContext).getRequest()
+                : (HttpRequest) httpContext.getAttribute(HttpCoreContext.HTTP_REQUEST);
+        if (request == null) {
+            return ret;
+        }
+        URI uri = request.getUri();
+        String url = uri.toURL().toString();
+
         AbstractSpan localSpan = ContextManager.createLocalSpan("httpasyncclient/local");
         localSpan.setComponent(ComponentsDefine.HTTP_ASYNC_CLIENT);
         localSpan.setLayer(SpanLayer.HTTP);
-        ContextManager.continued(snapshot);
+        ContextManager.continued(spans.getSnapshot());
 
         final ContextCarrier contextCarrier = new ContextCarrier();
-        BasicHttpRequest request = (BasicHttpRequest) httpContext.getAttribute(HttpClientContext.HTTP_REQUEST);
-        URI uri = request.getUri();
-
         String operationName = uri.getPath();
         int port = uri.getPort();
         AbstractSpan span = ContextManager
                 .createExitSpan(operationName, contextCarrier, uri.getHost() + ":" + (port == -1 ? 80 : port));
         span.setComponent(ComponentsDefine.HTTP_ASYNC_CLIENT);
-        Tags.URL.set(span, uri.toURL().toString());
+        Tags.URL.set(span, url);
         Tags.HTTP.METHOD.set(span, request.getMethod());
         SpanLayer.asHttp(span);
         CarrierItem next = contextCarrier.items();
@@ -81,6 +87,14 @@ public class IOSessionImplPollInterceptor implements InstanceMethodsAroundInterc
             next = next.next();
             request.setHeader(next.getHeadKey(), next.getHeadValue());
         }
+
+        // This reactor thread serves many connections, other requests are polled before this response arrives.
+        // Leave nothing on its span stack, the spans are finished by AsyncRequestSpans.
+        span.prepareForAsync();
+        ContextManager.stopSpan(span);
+        localSpan.prepareForAsync();
+        ContextManager.stopSpan(localSpan);
+        spans.start(localSpan, span);
         return ret;
     }
 
diff --git a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/wrapper/AsyncResponseConsumerWrapper.java b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/wrapper/AsyncResponseConsumerWrapper.java
index 9dec7d109a..3db8ea558b 100644
--- a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/wrapper/AsyncResponseConsumerWrapper.java
+++ b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/wrapper/AsyncResponseConsumerWrapper.java
@@ -25,57 +25,46 @@ import org.apache.hc.core5.http.HttpResponse;
 import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
 import org.apache.hc.core5.http.nio.CapacityChannel;
 import org.apache.hc.core5.http.protocol.HttpContext;
-import org.apache.skywalking.apm.agent.core.context.ContextManager;
-import org.apache.skywalking.apm.agent.core.context.tag.Tags;
-import org.apache.skywalking.apm.agent.core.context.trace.AbstractSpan;
+import org.apache.skywalking.apm.plugin.httpclient.v5.AsyncRequestSpans;
 
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.util.List;
 
+/**
+ * Finishes the spans of the request through {@link AsyncRequestSpans}, never through the span stack of the current
+ * thread, because the result may be consumed on a thread other than the one that created the spans, e.g. the caller
+ * thread of {@code HttpAsyncClients.classic(...)}.
+ */
 public class AsyncResponseConsumerWrapper<T> implements AsyncResponseConsumer<T> {
 
-    private AsyncResponseConsumer<T> consumer;
+    private final AsyncResponseConsumer<T> consumer;
+    private final AsyncRequestSpans spans;
 
-    public AsyncResponseConsumerWrapper(AsyncResponseConsumer<T> consumer) {
+    public AsyncResponseConsumerWrapper(AsyncResponseConsumer<T> consumer, AsyncRequestSpans spans) {
         this.consumer = consumer;
+        this.spans = spans;
     }
 
     @Override
     public void consumeResponse(HttpResponse response, EntityDetails entityDetails, HttpContext context,
             FutureCallback<T> resultCallback) throws HttpException, IOException {
-        if (ContextManager.isActive()) {
-            int statusCode = response.getCode();
-            AbstractSpan span = ContextManager.activeSpan();
-            Tags.HTTP_RESPONSE_STATUS_CODE.set(span, statusCode);
-            if (statusCode >= 400) {
-                span.errorOccurred();
-            }
-            ContextManager.stopSpan();
+        spans.onResponse(response.getCode());
+        if (entityDetails == null) {
+            // no entity, streamEnd will not be called
+            spans.onComplete();
         }
         consumer.consumeResponse(response, entityDetails, context, resultCallback);
     }
 
     @Override
     public void informationResponse(HttpResponse response, HttpContext context) throws HttpException, IOException {
-        if (ContextManager.isActive()) {
-            int statusCode = response.getCode();
-            AbstractSpan span = ContextManager.activeSpan();
-            Tags.HTTP_RESPONSE_STATUS_CODE.set(span, statusCode);
-            if (statusCode >= 400) {
-                span.errorOccurred();
-            }
-            ContextManager.stopSpan();
-        }
         consumer.informationResponse(response, context);
     }
 
     @Override
     public void failed(Exception cause) {
-        if (ContextManager.isActive()) {
-            ContextManager.activeSpan().errorOccurred().log(cause);
-            ContextManager.stopSpan();
-        }
+        spans.onFailure(cause);
         consumer.failed(cause);
     }
 
@@ -91,11 +80,14 @@ public class AsyncResponseConsumerWrapper<T> implements AsyncResponseConsumer<T>
 
     @Override
     public void streamEnd(List<? extends Header> trailers) throws HttpException, IOException {
+        spans.onComplete();
         consumer.streamEnd(trailers);
     }
 
     @Override
     public void releaseResources() {
+        // the exchange is over, e.g. cancelled, finish whatever is still open
+        spans.onComplete();
         consumer.releaseResources();
     }
 }
diff --git a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/wrapper/FutureCallbackWrapper.java b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/wrapper/FutureCallbackWrapper.java
deleted file mode 100644
index f606856edf..0000000000
--- a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/wrapper/FutureCallbackWrapper.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * 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.
- */
-
-package org.apache.skywalking.apm.plugin.httpclient.v5.wrapper;
-
-import org.apache.hc.core5.concurrent.FutureCallback;
-import org.apache.skywalking.apm.agent.core.context.ContextManager;
-
-public class FutureCallbackWrapper<T> implements FutureCallback<T> {
-
-    private FutureCallback<T> callback;
-
-    public FutureCallbackWrapper(FutureCallback<T> callback) {
-        this.callback = callback;
-    }
-
-    @Override
-    public void completed(T o) {
-        if (ContextManager.isActive()) {
-            ContextManager.stopSpan();
-        }
-        if (callback != null) {
-            callback.completed(o);
-        }
-    }
-
-    @Override
-    public void failed(Exception e) {
-        if (ContextManager.isActive()) {
-            ContextManager.activeSpan().errorOccurred().log(e);
-            ContextManager.stopSpan();
-        }
-        if (callback != null) {
-            callback.failed(e);
-        }
-    }
-
-    @Override
-    public void cancelled() {
-        if (ContextManager.isActive()) {
-            ContextManager.activeSpan().errorOccurred();
-            ContextManager.stopSpan();
-        }
-        if (callback != null) {
-            callback.cancelled();
-        }
-    }
-}
Unit tests for the patch (HttpAsyncClientTracingTest)
diff --git a/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/test/java/org/apache/skywalking/apm/plugin/httpclient/v5/HttpAsyncClientTracingTest.java b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/test/java/org/apache/skywalking/apm/plugin/httpclient/v5/HttpAsyncClientTracingTest.java
new file mode 100644
index 0000000000..4832403ec6
--- /dev/null
+++ b/apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/test/java/org/apache/skywalking/apm/plugin/httpclient/v5/HttpAsyncClientTracingTest.java
@@ -0,0 +1,310 @@
+/*
+ * 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.
+ *
+ */
+
+package org.apache.skywalking.apm.plugin.httpclient.v5;
+
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler;
+import org.apache.hc.core5.http.nio.AsyncRequestProducer;
+import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
+import org.apache.hc.core5.http.nio.command.RequestExecutionCommand;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.skywalking.apm.agent.core.context.ContextManager;
+import org.apache.skywalking.apm.agent.core.context.trace.AbstractSpan;
+import org.apache.skywalking.apm.agent.core.context.trace.AbstractTracingSpan;
+import org.apache.skywalking.apm.agent.core.context.trace.TraceSegment;
+import org.apache.skywalking.apm.agent.core.context.util.TagValuePair;
+import org.apache.skywalking.apm.agent.test.helper.SegmentHelper;
+import org.apache.skywalking.apm.agent.test.helper.SpanHelper;
+import org.apache.skywalking.apm.agent.test.tools.AgentServiceRule;
+import org.apache.skywalking.apm.agent.test.tools.SegmentStorage;
+import org.apache.skywalking.apm.agent.test.tools.SegmentStoragePoint;
+import org.apache.skywalking.apm.agent.test.tools.TracingSegmentRunner;
+import org.apache.skywalking.apm.plugin.httpclient.v5.wrapper.AsyncResponseConsumerWrapper;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.CoreMatchers.sameInstance;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * Drives the interceptors and the consumer wrapper in the thread model of the async client: the request is
+ * submitted on the caller thread, polled and answered on an I/O reactor thread, which serves many connections.
+ */
+@RunWith(TracingSegmentRunner.class)
+public class HttpAsyncClientTracingTest {
+
+    @SegmentStoragePoint
+    private SegmentStorage segmentStorage;
+
+    @Rule
+    public AgentServiceRule agentServiceRule = new AgentServiceRule();
+    @Rule
+    public MockitoRule rule = MockitoJUnit.rule();
+
+    @Mock
+    private AsyncRequestProducer requestProducer;
+    @Mock
+    private AsyncResponseConsumer<String> consumer;
+    @Mock
+    private FutureCallback<String> callback;
+    @Mock
+    private AsyncClientExchangeHandler exchangeHandler;
+    @Mock
+    private HttpResponse ok;
+    @Mock
+    private HttpResponse serverError;
+    @Mock
+    private EntityDetails entity;
+
+    private final HttpAsyncClientDoExecuteInterceptor doExecuteInterceptor = new HttpAsyncClientDoExecuteInterceptor();
+    private final IOSessionImplPollInterceptor pollInterceptor = new IOSessionImplPollInterceptor();
+    private ExecutorService reactor;
+
+    @Before
+    public void setUp() {
+        when(ok.getCode()).thenReturn(200);
+        when(serverError.getCode()).thenReturn(500);
+        reactor = Executors.newSingleThreadExecutor();
+    }
+
+    @After
+    public void tearDown() {
+        reactor.shutdownNow();
+    }
+
+    @Test
+    public void responseOnReactorThread() throws Throwable {
+        AbstractSpan entry = ContextManager.createEntrySpan("/caller", null);
+        Object[] request = submit(new BasicHttpContext(), "/a");
+        assertThat("the callback is not wrapped", request[5], sameInstance(callback));
+
+        onReactor(() -> {
+            poll(request);
+            assertThat("nothing is left on the reactor thread", ContextManager.isActive(), is(false));
+            wrapper(request).consumeResponse(ok, entity, context(request), null);
+            wrapper(request).streamEnd(null);
+            return null;
+        });
+
+        assertThat(segmentStorage.getTraceSegments().size(), is(1));
+        TraceSegment ioSegment = segmentStorage.getTraceSegments().get(0);
+        assertThat(ioSegment.getRef(), notNullValue());
+        assertExitSpan(ioSegment, "/a", "200", false);
+
+        assertThat(ContextManager.activeSpan(), sameInstance(entry));
+        ContextManager.stopSpan(entry);
+        assertThat(segmentStorage.getTraceSegments().size(), is(2));
+    }
+
+    /**
+     * Two connections on the same reactor thread, request B is polled before the response of A arrives.
+     */
+    @Test
+    public void interleavedRequestsOnOneReactorThread() throws Throwable {
+        AbstractSpan entry = ContextManager.createEntrySpan("/caller", null);
+        Object[] a = submit(new BasicHttpContext(), "/a");
+        Object[] b = submit(new BasicHttpContext(), "/b");
+
+        onReactor(() -> {
+            poll(a);
+            poll(b);
+            wrapper(a).consumeResponse(ok, entity, context(a), null);
+            wrapper(b).consumeResponse(serverError, entity, context(b), null);
+            wrapper(b).streamEnd(null);
+            wrapper(a).streamEnd(null);
+            assertThat(ContextManager.isActive(), is(false));
+            return null;
+        });
+
+        List<TraceSegment> segments = segmentStorage.getTraceSegments();
+        assertThat(segments.size(), is(2));
+        assertExitSpan(segments.get(0), "/b", "500", true);
+        assertExitSpan(segments.get(1), "/a", "200", false);
+        ContextManager.stopSpan(entry);
+    }
+
+    /**
+     * {@code HttpAsyncClients.classic(...)}: the body is read, and the result callback fired, on the caller thread.
+     */
+    @Test
+    public void resultConsumedOnCallerThread() throws Throwable {
+        AbstractSpan entry = ContextManager.createEntrySpan("/caller", null);
+        Object[] request = submit(new BasicHttpContext(), "/a");
+
+        onReactor(() -> {
+            poll(request);
+            wrapper(request).consumeResponse(ok, entity, context(request), null);
+            wrapper(request).streamEnd(null);
+            return null;
+        });
+        assertThat(segmentStorage.getTraceSegments().size(), is(1));
+
+        ((FutureCallback<String>) request[5]).completed("body");
+        assertThat(ContextManager.activeSpan(), sameInstance(entry));
+        ContextManager.stopSpan(entry);
+        assertThat(segmentStorage.getTraceSegments().size(), is(2));
+    }
+
+    @Test
+    public void failureAfterPoll() throws Throwable {
+        ContextManager.createEntrySpan("/caller", null);
+        Object[] request = submit(new BasicHttpContext(), "/a");
+
+        onReactor(() -> {
+            poll(request);
+            return null;
+        });
+        // e.g. a connection reset reported by another thread
+        wrapper(request).failed(new IOException("reset"));
+
+        assertThat(segmentStorage.getTraceSegments().size(), is(1));
+        for (AbstractTracingSpan span : SegmentHelper.getSpans(segmentStorage.getTraceSegments().get(0))) {
+            assertThat(SpanHelper.getErrorOccurred(span), is(true));
+            assertThat(SpanHelper.getLogs(span).size(), is(1));
+        }
+        ContextManager.stopSpan();
+    }
+
+    @Test
+    public void responseWithoutEntity() throws Throwable {
+        ContextManager.createEntrySpan("/caller", null);
+        Object[] request = submit(new BasicHttpContext(), "/a");
+
+        onReactor(() -> {
+            poll(request);
+            wrapper(request).consumeResponse(ok, null, context(request), null);
+            return null;
+        });
+
+        assertThat(segmentStorage.getTraceSegments().size(), is(1));
+        ContextManager.stopSpan();
+    }
+
+    @Test
+    public void releasedWithoutResponse() throws Throwable {
+        ContextManager.createEntrySpan("/caller", null);
+        Object[] request = submit(new BasicHttpContext(), "/a");
+
+        onReactor(() -> {
+            poll(request);
+            return null;
+        });
+        wrapper(request).releaseResources();
+        wrapper(request).releaseResources();
+
+        assertThat(segmentStorage.getTraceSegments().size(), is(1));
+        ContextManager.stopSpan();
+    }
+
+    /**
+     * Since 5.4, {@code CloseableHttpAsyncClient#execute(SimpleHttpRequest, FutureCallback)} passes a null context.
+     */
+    @Test
+    public void nullContextIsCreated() throws Throwable {
+        ContextManager.createEntrySpan("/caller", null);
+        Object[] request = submit(null, "/a");
+
+        assertThat(request[4], instanceOf(HttpClientContext.class));
+        assertThat(context(request).getAttribute(Constants.SKYWALKING_ASYNC_REQUEST_SPANS), notNullValue());
+        ContextManager.stopSpan();
+    }
+
+    @Test
+    public void untracedCallerIsNotEnhanced() throws Throwable {
+        Object[] request = submit(null, "/a");
+
+        assertThat(request[2], sameInstance(consumer));
+        assertThat(request[4], nullValue());
+    }
+
+    private Object[] submit(HttpContext context, String path) throws Throwable {
+        Object[] allArguments = new Object[] {null, requestProducer, consumer, null, context, callback};
+        doExecuteInterceptor.beforeMethod(null, null, allArguments, null, null);
+        if (allArguments[4] != null) {
+            context(allArguments).setAttribute(
+                HttpClientContext.HTTP_REQUEST, new BasicHttpRequest("GET", URI.create("http://127.0.0.1:8080" + path)));
+        }
+        return allArguments;
+    }
+
+    private void poll(Object[] request) throws Exception {
+        try {
+            pollInterceptor.afterMethod(
+                null, null, null, null, new RequestExecutionCommand(exchangeHandler, context(request)));
+        } catch (Exception e) {
+            throw e;
+        } catch (Throwable t) {
+            throw new IllegalStateException(t);
+        }
+    }
+
+    private void onReactor(Callable<Void> task) throws Exception {
+        reactor.submit(task).get();
+    }
+
+    private static HttpContext context(Object[] request) {
+        return (HttpContext) request[4];
+    }
+
+    @SuppressWarnings("unchecked")
+    private static AsyncResponseConsumerWrapper<String> wrapper(Object[] request) {
+        return (AsyncResponseConsumerWrapper<String>) request[2];
+    }
+
+    private static void assertExitSpan(TraceSegment segment, String operationName, String statusCode,
+                                       boolean error) {
+        List<AbstractTracingSpan> spans = SegmentHelper.getSpans(segment);
+        assertThat(spans.size(), is(2));
+        AbstractTracingSpan exit = spans.get(0);
+        assertThat(exit.isExit(), is(true));
+        assertThat(exit.getOperationName(), is(operationName));
+        assertThat(SpanHelper.getErrorOccurred(exit), is(error));
+        String status = null;
+        for (TagValuePair tag : SpanHelper.getTags(exit)) {
+            if (tag.getKey().key().equals("http.status_code")) {
+                status = tag.getValue();
+            }
+        }
+        assertThat(status, is(statusCode));
+        assertThat(spans.get(1).getOperationName(), is("httpasyncclient/local"));
+    }
+}
Probe endpoints used for the real-client runs (added to the httpclient-5.x-scenario app)
package org.apache.skywalking.testcase.httpclient5;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Future;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequests;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.reactor.IOReactorConfig;
import org.apache.hc.core5.util.Timeout;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/httpclient-5.x/probe")
public class ProbeController {

    private static final String BASE = "http://127.0.0.1:8080/httpclient-5.x/probe";

    @Autowired
    private CloseableHttpClient httpClient;

    /** One I/O reactor thread, so concurrent requests on different connections interleave on it. */
    private final CloseableHttpAsyncClient singleReactorClient = start(HttpAsyncClients.custom()
        .setIOReactorConfig(IOReactorConfig.custom().setIoThreadCount(1).build())
        .build());

    private final CloseableHttpAsyncClient classicBackend = start(HttpAsyncClients.createDefault());

    private static CloseableHttpAsyncClient start(CloseableHttpAsyncClient client) {
        client.start();
        return client;
    }

    @GetMapping("/slow")
    public String slow(@RequestParam("ms") long ms) throws InterruptedException {
        Thread.sleep(ms);
        return "slept " + ms;
    }

    /** Four overlapping requests answered out of order, then a sync call that must stay in the caller's segment. */
    @GetMapping("/concurrent")
    public String concurrent() throws Exception {
        List<Future<SimpleHttpResponse>> futures = new ArrayList<>();
        for (long ms : new long[] {400, 100, 300, 50}) {
            futures.add(singleReactorClient.execute(SimpleHttpRequests.get(BASE + "/slow?ms=" + ms), null));
        }
        StringBuilder result = new StringBuilder();
        for (Future<SimpleHttpResponse> future : futures) {
            result.append(future.get().getBodyText()).append(';');
        }
        afterward();
        return result.toString();
    }

    /** apache/skywalking#14097: the classic facade reads the body, and fires the callback, on this thread. */
    @GetMapping("/classic")
    public String classic() throws Exception {
        Method factory = HttpAsyncClients.class.getMethod("classic", CloseableHttpAsyncClient.class, Timeout.class);
        CloseableHttpClient classic = (CloseableHttpClient) factory.invoke(null, classicBackend, Timeout.ofSeconds(30));
        String body = classic.execute(new HttpGet(BASE + "/slow?ms=10"),
            response -> EntityUtils.toString(response.getEntity()));
        afterward();
        return body;
    }

    /** An exit span made after the async calls, it lands in the caller's segment only if the context survived. */
    private void afterward() throws Exception {
        httpClient.execute(new HttpGet(BASE + "/slow?ms=1"), response -> EntityUtils.toString(response.getEntity()));
    }
}

Run with -javaagent, the mock collector, and curl against /httpclient-5.x/probe/concurrent (twice) and /httpclient-5.x/probe/classic (5.5+). Then check receiveData:

  • every /probe/slow entry segment should have a CrossProcess ref to an httpasyncclient/local segment that was actually reported;
  • the caller's entry segment should still contain the sync /probe/slow exit span.

@xuzhiguang

Copy link
Copy Markdown
Contributor

I think we can simplify the fix by removing IOSessionImplPollInterceptor instrumentation entirely.

The original poll() approach may have assumed that FutureCallback runs on the reactor I/O thread. In that case, creating and closing spans on that thread could appear safe. However, with HttpAsyncClients.classic, FutureCallback can run on the business thread after the response body is consumed to EOF.

Therefore, ContextManager.stopSpan() in the callback may close the caller span on the business thread, instead of the HTTP span created on the I/O thread.

We could create the HTTP exit span in doExecute, while the caller context is active, then call prepareForAsync() and stop that specific span before returning. The callback wrapper keeps the AbstractSpan reference and calls asyncFinish() in completed / failed, rather than calling parameterless ContextManager.stopSpan().

We should also finish the retained span safely from cancellation or resource-release paths when completed is not invoked.

@Ayush0612005

Copy link
Copy Markdown
Author

Thanks for looking at this — I agree that retaining the span by reference and calling asyncFinish() instead of a parameterless stopSpan() in the callback is the right direction, and it's also consistent with the AsyncRequestSpans approach Wu Sheng suggested above.

One constraint with moving span creation into doExecute is that at that point we only have the AsyncRequestProducer, not the concrete HttpRequest. The actual request (URI, method, headers) is produced later by produceRequest on the I/O reactor, after poll(). We also need the concrete request to inject the sw8 propagation headers.

So I think we can keep the existing I/O-side creation while getting the behavior you're suggesting: create the local + exit spans in IOSessionImplPollInterceptor, immediately detach them with prepareForAsync() / stopSpan(), store them in an AsyncRequestSpans holder, and finish them by reference with asyncFinish() from the response/callback lifecycle, including cancellation and releaseResources().

I'll implement it this way and update the PR. Let me know if I'm missing something about why creating the span in doExecute would still give us access to the concrete request.

@xuzhiguang

Copy link
Copy Markdown
Contributor

We can wrap the AsyncRequestProducer argument in doExecute.

The wrapper delegates sendRequest, but supplies a wrapped RequestChannel. When the producer eventually calls RequestChannel.sendRequest(HttpRequest, EntityDetails, HttpContext), the wrapper receives the concrete HttpRequest and can inject the sw8 headers before forwarding it.

Therefore, we do not need IOSessionImplPollInterceptor just to obtain the request or inject propagation headers.

doExecute can create and detach the exit span while the caller context is active; the producer wrapper only updates request metadata and injects the carrier into the actual request. The response/callback lifecycle then finishes the retained span by asyncFinish().

@wu-sheng

Copy link
Copy Markdown
Member

+1 to @xuzhiguang's direction: wrap the AsyncRequestProducer, create the exit span in the caller's context, and drop the IOSessionImpl#poll instrumentation. I checked it against the httpclient5 5.0 and 5.5.2 sources and had the result cross-checked by a second independent review. The approach holds up, but a few details have to be handled carefully. Below are the recommended code changes for this PR. They are a sketch: not compiled or tested yet.

Facts the design relies on (verified in source)

  • All four instrumented clients hand the concrete request to the producer's RequestChannel synchronously, on the caller thread, inside doExecute:
    • Internal*AsyncClient: InternalAbstractHttpAsyncClient#doExecute calls requestProducer.sendRequest directly.
    • Minimal*AsyncClient: AbstractMinimalHttpAsyncClientBase#doExecute calls execute(), which calls BasicClientExchangeHandler#produceRequest, which calls the producer.
    • The classic facade: ClassicToAsyncRequestProducer#sendRequest also calls the channel directly.
  • Headers set on that request reach the wire, and retries copy them. 5.0 passes the same object on; 5.5.2 copies it with BasicRequestBuilder.copy.
  • AsyncRequestProducer and RequestChannel are identical in httpcore5 5.0 and 5.3.x.
  • prepareForAsync() must be called while the span is the active one (ContextManager.awaitFinishAsync checks this). asyncFinish() uses the span's own context, so it can run on any thread.

This also fixes 5.4+, where the plugin currently produces no async spans: the HttpContext can be null there, and the request is only put into the context after poll(). With this design the context is no longer needed.

Recommended changes

Remove: IOSessionImplPollInterceptor, IOSessionImplInstrumentation and its line in skywalking-plugin.def, Constants, and OwnedSpans.

New AsyncExitSpan, one per request, shared by the three wrappers:

public class AsyncExitSpan {

    private final HttpHost target;
    // the doExecute thread, where the caller's context is active; cleared once the span is created or doExecute returns
    private volatile Thread creator = Thread.currentThread();
    private AbstractSpan span;

    public AsyncExitSpan(HttpHost target) {
        this.target = target;
    }

    public HttpHost getTarget() {
        return target;
    }

    /** true only once, only on the doExecute thread, only before doExecute returns */
    public boolean claimCreation() {
        if (creator != Thread.currentThread()) {
            return false;
        }
        creator = null;
        return true;
    }

    public void callerReturned() {
        creator = null;
    }

    /** the span is already detached from the caller's stack, after prepareForAsync() */
    public synchronized void start(AbstractSpan span) {
        this.span = span;
    }

    public synchronized void onResponse(int statusCode) {
        if (span != null) {
            Tags.HTTP_RESPONSE_STATUS_CODE.set(span, statusCode);
            if (statusCode >= 400) {
                span.errorOccurred();
            }
        }
    }

    /** the whole response was received */
    public synchronized void finish() {
        end(false, null);
    }

    public synchronized void fail(Throwable cause) {
        end(true, cause);
    }

    /** cancelled, or released before the response completed */
    public synchronized void abort() {
        end(true, null);
    }

    private void end(boolean error, Throwable cause) {
        if (span == null) {
            return;
        }
        if (error) {
            span.errorOccurred();
        }
        if (cause != null) {
            span.log(cause);
        }
        span.asyncFinish();
        span = null;
    }
}

The methods are synchronized, not just an AtomicReference, because onResponse on the I/O thread could otherwise add a tag while another thread finishes the span. Span tags live in a plain ArrayList.

HttpAsyncClientDoExecuteInterceptor:

public void beforeMethod(...) {
    if (!ContextManager.isActive()) {
        return;
    }
    AsyncExitSpan exitSpan = new AsyncExitSpan((HttpHost) allArguments[0]);
    allArguments[1] = new AsyncRequestProducerWrapper((AsyncRequestProducer) allArguments[1], exitSpan);
    allArguments[2] = new AsyncResponseConsumerWrapper<>((AsyncResponseConsumer<?>) allArguments[2], exitSpan);
    // wrap even a null callback: it is where the future's end is reported
    allArguments[5] = new FutureCallbackWrapper<>((FutureCallback<?>) allArguments[5], exitSpan);
}

public Object afterMethod(...) {   // also runs after handleMethodException
    if (allArguments[1] instanceof AsyncRequestProducerWrapper) {
        ((AsyncRequestProducerWrapper) allArguments[1]).getExitSpan().callerReturned();
    }
    return ret;
}

public void handleMethodException(..., Throwable t) {
    if (allArguments[1] instanceof AsyncRequestProducerWrapper) {
        ((AsyncRequestProducerWrapper) allArguments[1]).getExitSpan().fail(t);
    }
}

doExecute no longer touches the HttpContext, which removes the NPE on 5.4+, where the context is null.

New AsyncRequestProducerWrapper (every other AsyncRequestProducer method just delegates):

@Override
public void sendRequest(RequestChannel channel, HttpContext context) throws HttpException, IOException {
    producer.sendRequest((request, entityDetails, ctx) -> {
        if (exitSpan.claimCreation()) {
            try {
                startExitSpan(request);
            } catch (Throwable t) {
                LOGGER.error(t, "Failed to trace the async HTTP request.");   // never fail the user's request
            }
        }
        channel.sendRequest(request, entityDetails, ctx);
    }, context);
}

private void startExitSpan(HttpRequest request) throws URISyntaxException {
    URI uri = request.getUri();
    HttpHost target = exitSpan.getTarget();
    // same precedence as InternalAbstractHttpAsyncClient: an explicit target wins over the request's authority
    String scheme = target != null ? target.getSchemeName() : uri.getScheme();
    String host = target != null ? target.getHostName() : uri.getHost();
    int port = target != null ? target.getPort() : uri.getPort();
    if (host == null) {
        return;
    }
    if (scheme == null) {
        scheme = "http";
    }
    if (port < 0) {
        port = "https".equalsIgnoreCase(scheme) ? 443 : 80;
    }
    String peer = host + ":" + port;
    String path = uri.getPath() == null || uri.getPath().isEmpty() ? "/" : uri.getPath();
    String url = scheme + "://" + peer + path + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery());

    // Inside another plugin's exit span, createExitSpan reuses that span (depth + 1). It must not be turned into an
    // async span, so then we only propagate, like the classic client does.
    boolean nested = ContextManager.activeSpan().isExit();
    // create without the carrier: createExitSpan(op, carrier, peer) injects before returning, and injection throws
    // for a reused outer exit span without a peer, which would leave the extra depth unbalanced
    AbstractSpan span = ContextManager.createExitSpan(path, peer);
    try {
        if (!nested) {
            span.setComponent(ComponentsDefine.HTTP_ASYNC_CLIENT);
            Tags.URL.set(span, url);
            Tags.HTTP.METHOD.set(span, request.getMethod());
            SpanLayer.asHttp(span);
        }
        ContextCarrier carrier = new ContextCarrier();
        ContextManager.inject(carrier);
        CarrierItem next = carrier.items();
        while (next.hasNext()) {
            next = next.next();
            request.setHeader(next.getHeadKey(), next.getHeadValue());
        }
    } finally {
        // detach BEFORE forwarding: the client may report a failure on this thread before doExecute returns
        if (!nested) {
            span.prepareForAsync();
        }
        ContextManager.stopSpan(span);
        if (!nested) {
            exitSpan.start(span);
        }
    }
}

AsyncResponseConsumerWrapper, which runs on the I/O thread:

consumeResponse:      exitSpan.onResponse(response.getCode());
                      if (entityDetails == null) { exitSpan.finish(); }   // no body, streamEnd won't come
                      consumer.consumeResponse(...);
streamEnd:            exitSpan.finish();                                  // last byte received
                      consumer.streamEnd(trailers);
failed:               exitSpan.fail(cause);
                      consumer.failed(cause);
releaseResources:     exitSpan.abort();                                   // see below
                      consumer.releaseResources();
informationResponse:  consumer.informationResponse(...);                  // 1xx: no span work

releaseResources() must not end the span as a success. HttpAsyncMainClientExec#failed calls the entity consumer's releaseResources() before it reports the failure, so after a 200 whose body then fails, a success finish would swallow the error. In the normal case the span is already finished at streamEnd or consumeResponse when release comes, so abort() does nothing. If the span is still open at release, the exchange ended without a complete response, so it is marked as an error. Release must still finish the span, though. In 5.5.2, AsyncRedirectExec can suppress a redirect response and then decline to resend a non-repeatable entity. It only calls asyncExecCallback.completed(), which releases the consumer and never completes the future. Without this fallback the span would never finish, and the caller's whole segment would be lost.

FutureCallbackWrapper runs on any thread (the caller's thread for HttpAsyncClients.classic). It must never call ContextManager.stopSpan():

completed(result):  exitSpan.finish();  if (callback != null) callback.completed(result);
failed(e):          exitSpan.fail(e);   if (callback != null) callback.failed(e);
cancelled():        exitSpan.abort();   if (callback != null) callback.cancelled();

It covers the paths the consumer never sees, such as the failure caught in doExecute's outer catch and cancellation. BasicFuture notifies the callback at most once. It does not guarantee that the future ends at all, so the callback is one of several ways the span gets finished, not the only one.

Resulting behaviour

  • The exit span belongs to the caller's segment. The httpasyncclient/local span and its cross-thread segment go away. The span covers the time from handing the request over to the last response byte, and nothing ever sits on the I/O reactor thread's span stack.
  • The caller's segment is reported only after the async span finishes. That's why every finish path above matters.
  • In an ignored (sampled-out) context, createExitSpan returns the no-op span and all of the above does nothing, so there is no real span and no usable sw8 header. Nothing breaks.

Known limitations to document

  • 5.4+ redirects that switch to GET (POST on 301/302, or non-GET/HEAD on 303) build a fresh request without the original headers (AsyncRedirectExec, BasicRequestBuilder.get()), so the redirected call is not linked. 5.0 copies the headers.
  • A custom AsyncRequestProducer that calls the channel later (the API allows it) is not traced. claimCreation() only allows the doExecute thread, because any other thread's context has nothing to do with this request. All the standard producers send synchronously.
  • The sw8 header is written onto the user's own request object, as in the sync plugin. A request object reused later without tracing still carries the old header.

Tests to add

Unit tests:

  • The request on the caller thread gets an sw8 header, the exit span lands in the caller's segment, and the caller's stack is left clean.
  • The classic facade's result arrives on the caller thread and leaves the caller's span alone.
  • A 200 whose body then fails, with release before failed, ends with an error.
  • A release without a response ends the span.
  • Cancellation.
  • A synchronous failure inside doExecute.
  • An explicit target wins the peer.
  • Nested inside another exit span.
  • Ignored context, and the span limit.
  • A producer that calls the channel from another thread creates no span.

Scenario:

  • Update expectedData.yaml. The httpasyncclient/local segment goes away, the /httpclient-5.x/back exit span moves into the asyncGet entry segment, and the downstream CrossProcess ref's parentEndpoint changes with it.
  • Consider adding a 5.5.x version, since 5.4+ becomes traceable. The scenario app ran fine with httpclient 5.5.2 on Spring Boot 1.5 and JDK 8 in my local runs.

A correction to my earlier review: the patch I posted there finished the spans as successful in releaseResources(), so it has the same error-loss problem described above. It's superseded by this design anyway.

@Ayush0612005

Copy link
Copy Markdown
Author

Thanks for the detailed review and for verifying the approach against both HttpClient 5.0 and 5.5.2. I agree with this direction.

I’ll rework the PR to wrap the AsyncRequestProducer, create and detach the exit span in the caller context, and remove the IOSessionImpl#poll instrumentation. The retained span will then be finished by reference with asyncFinish() from the response consumer and callback lifecycle, including failure, cancellation, and releaseResources() paths.

I’ll also add the suggested regression tests, update the scenario expectations, and verify the plugin against the relevant HttpClient versions before requesting another review.

Thanks again for catching the reactor-thread interleaving and lifecycle issues in the previous implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working plugin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants