Skip to content

Commit 1909516

Browse files
authored
Fix HttpClient 5.x callback span handling (#832)
Fix HttpClient 5.x async span lifecycle and propagation Create and detach async exit spans in the caller's context, then finish them by reference without disturbing the caller or reactor thread's span stack. Remove the IOSession poll instrumentation. Preserve response-processing exceptions, handle cancellation and resource release safely, and retain request authority and encoded URL paths. Enable async tracing for HttpClient 5.4+ and expand regression tests and scenario coverage across 5.0–5.6. Fixes apache/skywalking#14097
1 parent 7f681f7 commit 1909516

17 files changed

Lines changed: 1505 additions & 274 deletions

File tree

‎CHANGES.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ Release Notes.
1212
A concurrent request could overwrite it before the chain was subscribed, and the outbound `sw8` header then
1313
carried a context the downstream service joined by mistake. The snapshot is held by a client derived per
1414
request now (apache/skywalking#14095).
15+
* Fix the `httpclient-5.x-plugin` async client tracing (apache/skywalking#14097):
16+
* `FutureCallback` no longer stops a span. With `HttpAsyncClients.classic(...)` the callback runs on the caller
17+
thread, where it used to close the caller's own active span.
18+
* The HTTP exit span is now created in the caller's context when the request is sent, detached right away, and
19+
finished by reference when the response ends. Nothing is left on the I/O reactor thread's span stack any more,
20+
so overlapping requests on one reactor thread no longer nest into or close each other's spans.
21+
* The `httpasyncclient/local` span and its cross-thread segment are removed. The HTTP exit span now lives in the
22+
caller's own segment.
23+
* HttpClient 5.4+ async requests are traced now. Previously the plugin produced no spans for them, because
24+
`doExecute` received a `null` `HttpContext` and the request was not yet in the context when `IOSessionImpl#poll`
25+
ran.
26+
* A response that fails after its head, while the body is read or when the consumer builds the result at EOF,
27+
now ends the span as an error with the exception logged, instead of as a success or an error without a cause.
28+
* The destination is taken from the explicit target or the request authority, as the client routes it, so
29+
routable names that `java.net.URI` does not parse as a host (e.g. `service_name`) are traced and propagated.
30+
* The `url` tag keeps the path encoded as it was sent (e.g. `%2F` stays `%2F`).
31+
* The `httpclient-5.x-scenario` now tests one version per minor, 5.0 to 5.6.
1532
* Fix the `NullPointerException` thrown by the `spring-webflux-5.x-webclient` and
1633
`spring-webflux-6.x-webclient` plugins when `DefaultClientRequestBuilder$BodyInserterRequest#writeTo` runs
1734
before any exit span exists. Connectors such as `JdkClientHttpConnector` call `writeTo` eagerly at assembly
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package org.apache.skywalking.apm.plugin.httpclient.v5;
20+
21+
import java.util.concurrent.atomic.AtomicReference;
22+
import org.apache.hc.core5.http.HttpHost;
23+
import org.apache.skywalking.apm.agent.core.context.tag.Tags;
24+
import org.apache.skywalking.apm.agent.core.context.trace.AbstractSpan;
25+
26+
/**
27+
* Per-request async exit span, owned by the request itself rather than by whatever thread happens to be running
28+
* when a callback fires.
29+
*
30+
* <p>The span is created once, on the caller thread inside {@code doExecute}, while the caller's tracing context is
31+
* still active. It is then immediately detached via {@link AbstractSpan#prepareForAsync()} +
32+
* {@code ContextManager.stopSpan(span)} so it never sits on any thread's active-span stack while the request is in
33+
* flight. From that point on it is finished exactly once, by reference, from whichever lifecycle callback gets
34+
* there first (I/O thread response consumer, or the future callback on the caller/business thread) — never by a
35+
* parameterless {@code ContextManager.stopSpan()} that would blindly pop whatever span is currently active on that
36+
* thread.
37+
*
38+
* <p>All mutating operations are synchronized: {@link #onResponse(int)} (tagging, typically the I/O thread) can
39+
* otherwise race with {@link #finish()} / {@link #fail(Throwable)} (typically the response-consumer or callback
40+
* thread) finishing and clearing the span in the same window. An {@link AtomicReference} alone would prevent a
41+
* double-finish but not a tag-write racing a finish.
42+
*/
43+
public class AsyncRequestSpans {
44+
45+
private final HttpHost target;
46+
47+
/**
48+
* Only true, and only once, on the thread that is still inside {@code doExecute} when the request producer
49+
* hands the concrete request to the channel. Any other thread (a custom {@code AsyncRequestProducer} that
50+
* defers sending) has no relationship to the caller's context, so it must not create a span.
51+
*/
52+
private final AtomicReference<Thread> creator = new AtomicReference<>(Thread.currentThread());
53+
54+
private AbstractSpan span;
55+
private boolean finished;
56+
57+
/**
58+
* Set once the response head has been handed to the response consumer. From then on the client reports a
59+
* failure through {@code failed(cause)}, see {@link #release()}.
60+
*/
61+
private boolean responseStarted;
62+
63+
public AsyncRequestSpans(HttpHost target) {
64+
this.target = target;
65+
}
66+
67+
public HttpHost getTarget() {
68+
return target;
69+
}
70+
71+
/**
72+
* Claims the right to create the span. Returns {@code true} at most once, and only for the thread that
73+
* constructed this holder (the {@code doExecute} caller thread).
74+
*/
75+
public boolean claimCreation() {
76+
return creator.compareAndSet(Thread.currentThread(), null);
77+
}
78+
79+
/**
80+
* Called at the end of {@code doExecute} (success or failure) so a late/duplicate send from the same thread
81+
* cannot still claim creation after the caller has moved on.
82+
*/
83+
public void callerReturned() {
84+
creator.set(null);
85+
}
86+
87+
/**
88+
* Stores the span. Must be called only after the span has already been detached with
89+
* {@code prepareForAsync()} + {@code ContextManager.stopSpan(span)} — this class never touches the active-span
90+
* stack itself.
91+
*/
92+
public synchronized void start(AbstractSpan span) {
93+
this.span = span;
94+
}
95+
96+
public synchronized void onResponse(int statusCode) {
97+
responseStarted = true;
98+
99+
if (span == null || finished) {
100+
return;
101+
}
102+
103+
Tags.HTTP_RESPONSE_STATUS_CODE.set(span, statusCode);
104+
105+
if (statusCode >= 400) {
106+
span.errorOccurred();
107+
}
108+
}
109+
110+
/** The whole response completed successfully. */
111+
public synchronized void finish() {
112+
end(false, null);
113+
}
114+
115+
/** The exchange failed with an exception. */
116+
public synchronized void fail(Throwable cause) {
117+
end(true, cause);
118+
}
119+
120+
/**
121+
* The exchange was cancelled before it completed. Only takes effect if the span is still open; the
122+
* normal-completion paths already finished it earlier, so this is then a no-op.
123+
*/
124+
public synchronized void abort() {
125+
end(true, null);
126+
}
127+
128+
/**
129+
* The response consumer released its resources. In the normal case the span was already finished at
130+
* {@code streamEnd}/{@code consumeResponse}, so this does nothing.
131+
*
132+
* <p>If the response head was already seen and the span is still open, the body failed:
133+
* {@code HttpAsyncMainClientExec#failed} (and the H2 equivalent) releases the entity consumer first and then
134+
* reports the cause through {@code failed(cause)}. Finishing here would drop that exception, so the span is
135+
* left open for {@link #fail(Throwable)}.
136+
*
137+
* <p>If no response head was seen, release may be the only signal we get (e.g. a suppressed redirect with a
138+
* non-repeatable entity in 5.5.x never calls {@code failed} or {@code completed}), so the span is ended as
139+
* an error here.
140+
*/
141+
public synchronized void release() {
142+
if (responseStarted) {
143+
return;
144+
}
145+
146+
end(true, null);
147+
}
148+
149+
private void end(boolean error, Throwable cause) {
150+
if (span == null || finished) {
151+
return;
152+
}
153+
154+
finished = true;
155+
156+
if (error) {
157+
span.errorOccurred();
158+
}
159+
160+
if (cause != null) {
161+
span.log(cause);
162+
}
163+
164+
span.asyncFinish();
165+
span = null;
166+
}
167+
}

‎apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/Constants.java‎

Lines changed: 0 additions & 23 deletions
This file was deleted.

‎apm-sniffer/apm-sdk-plugin/httpclient-5.x-plugin/src/main/java/org/apache/skywalking/apm/plugin/httpclient/v5/HttpAsyncClientDoExecuteInterceptor.java‎

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,42 +18,93 @@
1818

1919
package org.apache.skywalking.apm.plugin.httpclient.v5;
2020

21+
import java.lang.reflect.Method;
2122
import org.apache.hc.core5.concurrent.FutureCallback;
23+
import org.apache.hc.core5.http.HttpHost;
24+
import org.apache.hc.core5.http.nio.AsyncRequestProducer;
2225
import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
23-
import org.apache.hc.core5.http.protocol.HttpContext;
2426
import org.apache.skywalking.apm.agent.core.context.ContextManager;
2527
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.EnhancedInstance;
2628
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstanceMethodsAroundInterceptor;
2729
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.MethodInterceptResult;
30+
import org.apache.skywalking.apm.plugin.httpclient.v5.wrapper.AsyncRequestProducerWrapper;
2831
import org.apache.skywalking.apm.plugin.httpclient.v5.wrapper.AsyncResponseConsumerWrapper;
2932
import org.apache.skywalking.apm.plugin.httpclient.v5.wrapper.FutureCallbackWrapper;
3033

31-
import java.lang.reflect.Method;
32-
34+
/**
35+
* Intercepts the internal {@code doExecute(HttpHost, AsyncRequestProducer, AsyncResponseConsumer, ..., FutureCallback)}
36+
* overload shared by every async client implementation (Internal*AsyncClient, Minimal*AsyncClient, and the
37+
* classic-facade adapter), whose argument order/types are identical across HttpClient 5.0 through 5.6.
38+
*
39+
* <p>It does not store anything in the {@code HttpContext} and never touches the span stack of a thread other than
40+
* the caller's. It only:
41+
* <ol>
42+
* <li>creates a per-request {@link AsyncRequestSpans} holder, while the caller's context is still active;</li>
43+
* <li>wraps the request producer so the exit span is created on the caller thread, synchronously, the moment the
44+
* concrete {@code HttpRequest} becomes available;</li>
45+
* <li>wraps the response consumer and future callback so the retained span is finished by reference.</li>
46+
* </ol>
47+
* Because span creation no longer depends on the {@code HttpContext}, this also fixes HttpClient 5.4+, where the
48+
* context argument passed by the classic facade and by {@code execute(SimpleHttpRequest, FutureCallback)} is
49+
* {@code null}.
50+
*/
3351
public class HttpAsyncClientDoExecuteInterceptor implements InstanceMethodsAroundInterceptor {
3452

53+
private static final int TARGET_INDEX = 0;
54+
private static final int REQUEST_PRODUCER_INDEX = 1;
55+
private static final int RESPONSE_CONSUMER_INDEX = 2;
56+
private static final int CALLBACK_INDEX = 5;
57+
3558
@Override
36-
public void beforeMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
37-
MethodInterceptResult result) throws Throwable {
38-
AsyncResponseConsumer consumer = (AsyncResponseConsumer) allArguments[2];
39-
HttpContext context = (HttpContext) allArguments[4];
40-
FutureCallback callback = (FutureCallback) allArguments[5];
41-
allArguments[2] = new AsyncResponseConsumerWrapper(consumer);
42-
allArguments[5] = new FutureCallbackWrapper(callback);
43-
if (ContextManager.isActive()) {
44-
context.setAttribute(Constants.SKYWALKING_CONTEXT_SNAPSHOT, ContextManager.capture());
59+
public void beforeMethod(EnhancedInstance objInst, Method method, Object[] allArguments,
60+
Class<?>[] argumentsTypes, MethodInterceptResult result) throws Throwable {
61+
if (!ContextManager.isActive()) {
62+
return;
4563
}
64+
if (!(allArguments[REQUEST_PRODUCER_INDEX] instanceof AsyncRequestProducer)
65+
|| !(allArguments[RESPONSE_CONSUMER_INDEX] instanceof AsyncResponseConsumer)) {
66+
return;
67+
}
68+
69+
final HttpHost target = allArguments[TARGET_INDEX] instanceof HttpHost
70+
? (HttpHost) allArguments[TARGET_INDEX] : null;
71+
final AsyncRequestSpans spans = new AsyncRequestSpans(target);
72+
73+
allArguments[REQUEST_PRODUCER_INDEX] = new AsyncRequestProducerWrapper(
74+
(AsyncRequestProducer) allArguments[REQUEST_PRODUCER_INDEX], spans);
75+
allArguments[RESPONSE_CONSUMER_INDEX] = new AsyncResponseConsumerWrapper<>(
76+
(AsyncResponseConsumer<?>) allArguments[RESPONSE_CONSUMER_INDEX], spans);
77+
// Wrap even when the caller passed null: it's the only lifecycle hook that sees cancellation and the
78+
// synchronous-failure-before-consumer-runs path for callers who supplied no callback of their own.
79+
allArguments[CALLBACK_INDEX] = new FutureCallbackWrapper<>(
80+
(FutureCallback<?>) allArguments[CALLBACK_INDEX], spans);
4681
}
4782

4883
@Override
49-
public Object afterMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
50-
Object ret) throws Throwable {
84+
public Object afterMethod(EnhancedInstance objInst, Method method, Object[] allArguments,
85+
Class<?>[] argumentsTypes, Object ret) throws Throwable {
86+
releaseCreationClaim(allArguments);
5187
return ret;
5288
}
5389

5490
@Override
5591
public void handleMethodException(EnhancedInstance objInst, Method method, Object[] allArguments,
56-
Class<?>[] argumentsTypes, Throwable t) {
92+
Class<?>[] argumentsTypes, Throwable t) {
93+
if (allArguments[REQUEST_PRODUCER_INDEX] instanceof AsyncRequestProducerWrapper) {
94+
AsyncRequestProducerWrapper wrapper = (AsyncRequestProducerWrapper) allArguments[REQUEST_PRODUCER_INDEX];
95+
wrapper.getSpans().fail(t);
96+
}
97+
releaseCreationClaim(allArguments);
98+
}
5799

100+
/**
101+
* Once {@code doExecute} has returned (or thrown), no thread other than a genuinely deferred custom producer
102+
* has any business claiming span creation — clearing this here keeps {@link AsyncRequestSpans#claimCreation()}
103+
* honest even if the same thread somehow re-enters.
104+
*/
105+
private void releaseCreationClaim(Object[] allArguments) {
106+
if (allArguments[REQUEST_PRODUCER_INDEX] instanceof AsyncRequestProducerWrapper) {
107+
((AsyncRequestProducerWrapper) allArguments[REQUEST_PRODUCER_INDEX]).getSpans().callerReturned();
108+
}
58109
}
59110
}

0 commit comments

Comments
 (0)