Skip to content

Commit 269f42c

Browse files
dougqhclaude
andcommitted
fix(okhttp): split AsyncCall scope capture by major version, verify 4.x/5.x
Address review feedback on the virtual-thread Dispatcher trace-contamination fix: - Split the AsyncCall.<init> scope capture into per-major instrumentations so each is tested against its own OkHttp version. okhttp-3.0 keeps the v3 type (okhttp3.RealCall$AsyncCall); a new okhttp-4.0 module matches the relocated v4+ type (okhttp3.internal.connection.RealCall$AsyncCall). The okhttp-4.0 suite runs the regression against OkHttp 4.12.0 and 5.4.0 (5.x is Kotlin Multiplatform; classes ship in okhttp-jvm, resolved via Gradle variant metadata). Muzzle covers 4.0.0 -> 5.4.0. - Both instrumentations now extend InstrumenterModule.ContextTracking (matching RunnableInstrumentation, the consumer of the state they write) rather than Tracing -- their job is scope propagation, not span creation. - Drop the redundant cachedThreadPoolDispatcher test (covered by OkHttp3AsyncTest) and document that only the concurrent test exercises the contamination path. - Cap the vthread suites at maxJavaVersion=24 to match java-concurrent-21.0, which provides the virtual-thread propagation they depend on and is only supported through JDK 24. This fixes the TimeoutExceptions on the JDK 25 / 26 ("tip") CI shards, where that propagation chain isn't active. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 84979ca commit 269f42c

7 files changed

Lines changed: 466 additions & 25 deletions

File tree

dd-java-agent/instrumentation/okhttp/okhttp-3.0/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ tasks.named("compileVthread21TestJava", JavaCompile) {
2525
tasks.named("vthread21Test", Test) {
2626
testJvmConstraints {
2727
minJavaVersion = JavaVersion.VERSION_21
28+
// Cap at 24 to match java-concurrent-21.0, which provides the virtual-thread scope propagation
29+
// (TaskRunner/VirtualThread instrumentation) this suite relies on and is only supported through
30+
// JDK 24. Without this, the suite runs on the JDK 25 / "tip" (26) CI shards and times out
31+
// because that propagation chain isn't active there.
32+
maxJavaVersion = JavaVersion.VERSION_24
2833
}
2934
}
3035

dd-java-agent/instrumentation/okhttp/okhttp-3.0/src/main/java/datadog/trace/instrumentation/okhttp3/AsyncCallInstrumentation.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,30 @@
2828
* okhttp.request} spans cross-contaminate between traces.
2929
*
3030
* <p>{@code AsyncCall} is an inner class of {@code RealCall} and transitively implements {@link
31-
* Runnable}.
31+
* Runnable}. This module targets OkHttp 3.x, where it lives at {@code okhttp3.RealCall$AsyncCall}.
32+
* OkHttp 4.x+ relocated it to {@code okhttp3.internal.connection.RealCall$AsyncCall}, which is
33+
* handled by the separate {@code okhttp-4.0} module.
3234
*/
3335
@AutoService(InstrumenterModule.class)
34-
public final class AsyncCallInstrumentation extends InstrumenterModule.Tracing
36+
public final class AsyncCallInstrumentation extends InstrumenterModule.ContextTracking
3537
implements Instrumenter.ForKnownTypes, Instrumenter.HasMethodAdvice {
3638

3739
public AsyncCallInstrumentation() {
3840
// Re-use the existing "okhttp" / "okhttp-3" instrumentation names so we don't introduce a
3941
// separately-toggleable feature flag (DD_TRACE_OKHTTP_ASYNC_CALL_ENABLED). The capture here
4042
// is conceptually part of the OkHttp instrumentation — if you disable OkHttp tracing, you
4143
// also disable this capture, which is the right behavior.
44+
//
45+
// This is a ContextTracking module (like RunnableInstrumentation, which consumes the state we
46+
// write) rather than a Tracing module: its sole job is to propagate the captured scope through
47+
// the shared ContextStore<Runnable, State>, not to create spans of its own.
4248
super("okhttp", "okhttp-3");
4349
}
4450

4551
@Override
4652
public String[] knownMatchingTypes() {
4753
return new String[] {
48-
"okhttp3.RealCall$AsyncCall",
54+
"okhttp3.RealCall$AsyncCall", // OkHttp 3.x
4955
};
5056
}
5157

dd-java-agent/instrumentation/okhttp/okhttp-3.0/src/vthread21Test/java/OkHttpVirtualThreadDispatcherTest.java

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,19 @@
3434
* {@code Executors.newCachedThreadPool(...)} to {@code
3535
* Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name(prefix, start).factory())}.
3636
*
37-
* <p>The test runs both shapes against the same {@link HttpServer} mock. Inside a manually
38-
* activated "parent" span it does {@code client.newCall(request).enqueue(callback)} and waits on a
39-
* latch for the callback. The agent's OkHttp instrumentation injects {@code TracingInterceptor},
40-
* which creates the {@code okhttp.request} client span using whatever scope is active on the
41-
* dispatcher worker. The assertions verify the client span lands under the parent &mdash; i.e., the
42-
* dispatcher's worker thread saw the propagated scope.
37+
* <p>Each test runs against the same {@link HttpServer} mock. Inside a manually activated "parent"
38+
* span it does {@code client.newCall(request).enqueue(callback)} and waits on a latch for the
39+
* callback. The agent's OkHttp instrumentation injects {@code TracingInterceptor}, which creates
40+
* the {@code okhttp.request} client span using whatever scope is active on the dispatcher worker.
41+
* The assertions verify the client span lands under the parent &mdash; i.e., the dispatcher's
42+
* worker thread saw the propagated scope.
4343
*
44-
* <p>If propagation fails for the virtual-thread shape (the failure profiling-backend is reporting)
45-
* the {@code okhttp.request} span will either become a root span in its own trace or be parented
46-
* under nothing, and {@code assertEquals(parentSpan.getSpanId(), okhttpSpan.getParentId())} fails.
44+
* <p>{@code concurrentVirtualThreadPerTaskDispatcher_keepsEachTraceSeparate} is the regression test
45+
* for the fix: it forces dispatcher-queue contention so calls are promoted from {@code
46+
* Dispatcher.finished()} on a sibling's worker thread, and fails (cross-trace contamination)
47+
* without the {@code AsyncCall.<init>} scope capture. {@code virtualThreadPerTaskDispatcher_…} is a
48+
* single-shot baseline: it confirms basic propagation through the virtual-thread dispatcher but
49+
* does not by itself exercise the contamination path (single-shot requests never queue).
4750
*/
4851
class OkHttpVirtualThreadDispatcherTest extends AbstractInstrumentationTest {
4952

@@ -114,19 +117,6 @@ public void onFailure(Call call, IOException e) {
114117
}
115118
}
116119

117-
@Test
118-
void cachedThreadPoolDispatcher_parentsOkHttpSpanUnderParent() throws Exception {
119-
ExecutorService dispatcherExecutor = Executors.newCachedThreadPool();
120-
OkHttpClient client = buildClient(dispatcherExecutor);
121-
try {
122-
runUnderParent(client, new CountDownLatch(1));
123-
} finally {
124-
dispatcherExecutor.shutdown();
125-
}
126-
127-
assertOkHttpSpanParentedUnderParent();
128-
}
129-
130120
@Test
131121
void virtualThreadPerTaskDispatcher_parentsOkHttpSpanUnderParent() throws Exception {
132122
// Exact shape from profiling-backend PR#8520.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
muzzle {
2+
// No assertInverse: the advice references only java.lang.Runnable plus agent-bootstrap helpers,
3+
// never an OkHttp type, so muzzle has nothing version-specific to fail on for pre-4.x. The real
4+
// version gate is knownMatchingTypes — okhttp3.internal.connection.RealCall$AsyncCall only exists
5+
// in 4.x+, so the instrumentation simply doesn't match on 3.x (which okhttp-3.0 handles instead).
6+
pass {
7+
group = "com.squareup.okhttp3"
8+
module = "okhttp"
9+
versions = "[4.0,)"
10+
}
11+
}
12+
13+
apply from: "$rootDir/gradle/java.gradle"
14+
// Use slf4j-simple for tests; logback's synchronized appenders can pin virtual-thread carriers
15+
// when many vthreads log concurrently, which deadlocks the virtual-thread suites.
16+
apply from: "$rootDir/gradle/slf4j-simple.gradle"
17+
18+
// The only coverage here is the virtual-thread regression suite, which needs the JDK 21+ API. We
19+
// run the same test source against both OkHttp 4.x and 5.x:
20+
// - 4.x lives at com.squareup.okhttp3:okhttp
21+
// - 5.x became a Kotlin-Multiplatform library; com.squareup.okhttp3:okhttp is a metadata shell and
22+
// the JVM classes ship in com.squareup.okhttp3:okhttp-jvm (Gradle resolves this automatically
23+
// via variant metadata). The matched type okhttp3.internal.connection.RealCall$AsyncCall is at
24+
// the same FQN in both, so a single instrumentation covers both majors.
25+
// Both suites share src/vthread21Test/java and are kept apart only to bind different OkHttp versions.
26+
addTestSuiteForDir('vthread21Test4', 'vthread21Test')
27+
addTestSuiteForDir('vthread21Test5', 'vthread21Test')
28+
29+
['vthread21Test4', 'vthread21Test5'].each { suite ->
30+
tasks.named("compile${suite.capitalize()}Java", JavaCompile) {
31+
configureCompiler(it, 21)
32+
}
33+
tasks.named(suite, Test) {
34+
testJvmConstraints {
35+
minJavaVersion = JavaVersion.VERSION_21
36+
// Cap at 24 to match java-concurrent-21.0, which provides the virtual-thread scope propagation
37+
// these suites rely on and is only supported through JDK 24. See okhttp-3.0 for details.
38+
maxJavaVersion = JavaVersion.VERSION_24
39+
}
40+
}
41+
tasks.named("check") {
42+
dependsOn suite
43+
}
44+
}
45+
46+
dependencies {
47+
compileOnly(group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.0.0')
48+
49+
// OkHttp 4.x (Kotlin) pulls kotlin-stdlib and okio 2.x transitively.
50+
vthread21Test4Implementation(group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.12.0')
51+
// OkHttp 5.x resolves to okhttp-jvm via Gradle variant metadata; pulls kotlin-stdlib 2.x + okio 3.x.
52+
vthread21Test5Implementation(group: 'com.squareup.okhttp3', name: 'okhttp', version: '5.4.0')
53+
54+
// The core OkHttp tracing (OkHttp3Instrumentation -> TracingInterceptor, which creates the
55+
// okhttp.request span) lives in the okhttp-3.0 module and applies to 4.x/5.x too. Pull it in so
56+
// the tests exercise the full chain: core tracing + this module's AsyncCall scope capture.
57+
vthread21Test4RuntimeOnly project(':dd-java-agent:instrumentation:okhttp:okhttp-3.0')
58+
vthread21Test5RuntimeOnly project(':dd-java-agent:instrumentation:okhttp:okhttp-3.0')
59+
60+
// Pull in the JDK-21+ concurrent / lang instrumentations so the tests install the same
61+
// TaskRunnerInstrumentation + VirtualThreadInstrumentation chain that profiling-backend
62+
// exercises in production.
63+
vthread21Test4RuntimeOnly project(':dd-java-agent:instrumentation:java:java-concurrent:java-concurrent-21.0')
64+
vthread21Test4RuntimeOnly project(':dd-java-agent:instrumentation:java:java-lang:java-lang-21.0')
65+
vthread21Test5RuntimeOnly project(':dd-java-agent:instrumentation:java:java-concurrent:java-concurrent-21.0')
66+
vthread21Test5RuntimeOnly project(':dd-java-agent:instrumentation:java:java-lang:java-lang-21.0')
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package datadog.trace.instrumentation.okhttp4;
2+
3+
import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.capture;
4+
import static java.util.Collections.singletonMap;
5+
import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
6+
7+
import com.google.auto.service.AutoService;
8+
import datadog.trace.agent.tooling.Instrumenter;
9+
import datadog.trace.agent.tooling.InstrumenterModule;
10+
import datadog.trace.bootstrap.InstrumentationContext;
11+
import datadog.trace.bootstrap.instrumentation.java.concurrent.State;
12+
import java.util.Map;
13+
import net.bytebuddy.asm.Advice;
14+
15+
/**
16+
* OkHttp 4.x+ variant of the {@code AsyncCall.<init>} scope capture. Identical in behavior to the
17+
* {@code okhttp-3.0} module's instrumentation — see that class for the full explanation of the
18+
* dispatcher-recursion failure mode — but matches the relocated 4.x type.
19+
*
20+
* <p>{@code AsyncCall} is an inner class of {@code RealCall} and transitively implements {@link
21+
* Runnable}. OkHttp 4.x moved {@code RealCall} from {@code okhttp3} into {@code
22+
* okhttp3.internal.connection}, so the nested type is {@code
23+
* okhttp3.internal.connection.RealCall$AsyncCall}.
24+
*/
25+
@AutoService(InstrumenterModule.class)
26+
public final class AsyncCallInstrumentation extends InstrumenterModule.ContextTracking
27+
implements Instrumenter.ForKnownTypes, Instrumenter.HasMethodAdvice {
28+
29+
public AsyncCallInstrumentation() {
30+
// Re-use the existing "okhttp" instrumentation name so this capture is enabled/disabled with
31+
// OkHttp tracing as a whole, rather than introducing a separately-toggleable feature flag.
32+
//
33+
// This is a ContextTracking module (like RunnableInstrumentation, which consumes the state we
34+
// write) rather than a Tracing module: its sole job is to propagate the captured scope through
35+
// the shared ContextStore<Runnable, State>, not to create spans of its own.
36+
super("okhttp", "okhttp-4");
37+
}
38+
39+
@Override
40+
public String[] knownMatchingTypes() {
41+
return new String[] {
42+
"okhttp3.internal.connection.RealCall$AsyncCall", // OkHttp 4.x+
43+
};
44+
}
45+
46+
@Override
47+
public Map<String, String> contextStore() {
48+
// Same Runnable -> State store that RunnableInstrumentation reads from.
49+
return singletonMap("java.lang.Runnable", State.class.getName());
50+
}
51+
52+
@Override
53+
public void methodAdvice(MethodTransformer transformer) {
54+
transformer.applyAdvice(isConstructor(), getClass().getName() + "$Construct");
55+
}
56+
57+
public static final class Construct {
58+
@Advice.OnMethodExit(suppress = Throwable.class)
59+
public static void captureScope(@Advice.This Runnable asyncCall) {
60+
// AdviceUtils.capture is a no-op when async propagation is disabled or there's no active
61+
// span — same behavior as the rest of the concurrent instrumentation.
62+
capture(InstrumentationContext.get(Runnable.class, State.class), asyncCall);
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)