From eefb6703319dd01085745e0886bc90bcfb198791 Mon Sep 17 00:00:00 2001 From: Valentin Zakharov Date: Mon, 3 Aug 2026 00:44:33 +0200 Subject: [PATCH] Propagate trace context across Guidewire WSI SOAP worker threads --- .../guidewire-10.0/build.gradle | 21 ++++ .../WsiAsyncResponseInstrumentation.java | 87 ++++++++++++++ .../WsiAsyncResponseInstrumentationTest.java | 108 ++++++++++++++++++ .../gw/internal/xml/ws/AsyncResponseImpl.java | 59 ++++++++++ .../gw/internal/xml/ws/UnrelatedWorker.java | 17 +++ metadata/agent-jar-checks.properties | 1 + metadata/supported-configurations.json | 8 ++ settings.gradle.kts | 1 + 8 files changed, 302 insertions(+) create mode 100644 dd-java-agent/instrumentation/guidewire-10.0/build.gradle create mode 100644 dd-java-agent/instrumentation/guidewire-10.0/src/main/java/datadog/trace/instrumentation/guidewire/WsiAsyncResponseInstrumentation.java create mode 100644 dd-java-agent/instrumentation/guidewire-10.0/src/test/java/datadog/trace/instrumentation/guidewire/WsiAsyncResponseInstrumentationTest.java create mode 100644 dd-java-agent/instrumentation/guidewire-10.0/src/test/java/gw/internal/xml/ws/AsyncResponseImpl.java create mode 100644 dd-java-agent/instrumentation/guidewire-10.0/src/test/java/gw/internal/xml/ws/UnrelatedWorker.java diff --git a/dd-java-agent/instrumentation/guidewire-10.0/build.gradle b/dd-java-agent/instrumentation/guidewire-10.0/build.gradle new file mode 100644 index 00000000000..b80aecd1420 --- /dev/null +++ b/dd-java-agent/instrumentation/guidewire-10.0/build.gradle @@ -0,0 +1,21 @@ +// Guidewire is proprietary and not published to any repository, so this module has no +// compile dependency on it: the target classes are matched purely by name at runtime. +muzzle { + pass { + coreJdk() + } +} + +apply from: "${rootDir}/gradle/java.gradle" + +tasks.named("compileJava") { + configureCompiler(it, 8) +} + +dependencies { + // Not required (the module self-activates in $Activate); kept so the test also exercises the + // default config where RunnableInstrumentation wraps run() too — the double activation is safe. + testImplementation project(':dd-java-agent:instrumentation:java:java-concurrent:java-concurrent-1.8') + // @Trace on fixture methods, to materialize the child span whose parent we assert. + testImplementation project(':dd-java-agent:instrumentation:datadog:tracing:trace-annotation') +} diff --git a/dd-java-agent/instrumentation/guidewire-10.0/src/main/java/datadog/trace/instrumentation/guidewire/WsiAsyncResponseInstrumentation.java b/dd-java-agent/instrumentation/guidewire-10.0/src/main/java/datadog/trace/instrumentation/guidewire/WsiAsyncResponseInstrumentation.java new file mode 100644 index 00000000000..fa9ff044f1a --- /dev/null +++ b/dd-java-agent/instrumentation/guidewire-10.0/src/main/java/datadog/trace/instrumentation/guidewire/WsiAsyncResponseInstrumentation.java @@ -0,0 +1,87 @@ +package datadog.trace.instrumentation.guidewire; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.extendsClass; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.nameStartsWith; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.capture; +import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.endTaskScope; +import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.startTaskScope; +import static java.util.Collections.singletonMap; +import static net.bytebuddy.matcher.ElementMatchers.isConstructor; +import static net.bytebuddy.matcher.ElementMatchers.isPublic; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +import com.google.auto.service.AutoService; +import datadog.context.ContextScope; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.bootstrap.InstrumentationContext; +import datadog.trace.bootstrap.instrumentation.java.concurrent.State; +import java.util.Map; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +/** + * Propagates trace context across the raw thread Guidewire's WSI layer spawns per outbound SOAP + * call ({@code AsyncResponseImpl$WebserviceInvocationThread}, seen at runtime as {@code + * "WSI-Invocation"}). + * + *

{@code java.lang.Thread} can't be instrumented (agent global-ignore + bootstrap), so we match + * the application-loaded worker subclass instead: capture the context in its {@code } (still + * on the parent thread) and re-activate it in {@code run()}. Both halves live here so it works even + * if the runnable/executor integration is off; if that also wraps {@code run()}, the double + * activation is safe because the continuation is consumed once. + */ +@AutoService(InstrumenterModule.class) +public final class WsiAsyncResponseInstrumentation extends InstrumenterModule.ContextTracking + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + + private static final String ASYNC_RESPONSE = "gw.internal.xml.ws.AsyncResponseImpl"; + + public WsiAsyncResponseInstrumentation() { + super("guidewire"); + } + + @Override + public String hierarchyMarkerType() { + return ASYNC_RESPONSE; + } + + @Override + public ElementMatcher hierarchyMatcher() { + // '$' matches only nested classes of AsyncResponseImpl, not top-level siblings. + return nameStartsWith(ASYNC_RESPONSE + "$").and(extendsClass(named("java.lang.Thread"))); + } + + @Override + public Map contextStore() { + return singletonMap(Runnable.class.getName(), State.class.getName()); + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice(isConstructor(), getClass().getName() + "$Capture"); + transformer.applyAdvice( + named("run").and(takesArguments(0)).and(isPublic()), getClass().getName() + "$Activate"); + } + + public static final class Capture { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void onConstruct(@Advice.This final Runnable thiz) { + capture(InstrumentationContext.get(Runnable.class, State.class), thiz); + } + } + + public static final class Activate { + @Advice.OnMethodEnter(suppress = Throwable.class) + public static ContextScope enter(@Advice.This final Runnable thiz) { + return startTaskScope(InstrumentationContext.get(Runnable.class, State.class), thiz); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void exit(@Advice.Enter final ContextScope scope) { + endTaskScope(scope); + } + } +} diff --git a/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/datadog/trace/instrumentation/guidewire/WsiAsyncResponseInstrumentationTest.java b/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/datadog/trace/instrumentation/guidewire/WsiAsyncResponseInstrumentationTest.java new file mode 100644 index 00000000000..0e920b43ee1 --- /dev/null +++ b/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/datadog/trace/instrumentation/guidewire/WsiAsyncResponseInstrumentationTest.java @@ -0,0 +1,108 @@ +package datadog.trace.instrumentation.guidewire; + +import static datadog.trace.agent.test.assertions.SpanMatcher.span; +import static datadog.trace.agent.test.assertions.TraceMatcher.SORT_BY_START_TIME; +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; +import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.activateSpan; +import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.startSpan; + +import datadog.context.ContextScope; +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import gw.internal.xml.ws.AsyncResponseImpl; +import gw.internal.xml.ws.UnrelatedWorker; +import org.junit.jupiter.api.Test; + +class WsiAsyncResponseInstrumentationTest extends AbstractInstrumentationTest { + + @FunctionalInterface + interface Body { + void run() throws Exception; + } + + private static void runUnderTrace(String operationName, Body body) throws Exception { + AgentSpan span = startSpan("guidewire-test", operationName); + try (ContextScope scope = activateSpan(span)) { + body.run(); + } finally { + span.finish(); + } + } + + @Test + void namedWorkerPropagatesContext() throws Exception { + // Constructed and run while the caller's span is active; invoke() blocks until the worker ends. + runUnderTrace("parent", () -> new AsyncResponseImpl().invoke()); + + assertTraces( + trace( + SORT_BY_START_TIME, + span().root().operationName("parent"), + span().childOfPrevious().operationName("soap.call"))); + } + + @Test + void anonymousWorkerPropagatesContext() throws Exception { + runUnderTrace("parent", () -> AsyncResponseImpl.anonymous().invoke()); + + assertTraces( + trace( + SORT_BY_START_TIME, + span().root().operationName("parent"), + span().childOfPrevious().operationName("soap.call"))); + } + + @Test + void synchronousRunPropagatesContext() throws Exception { + // callTimeout <= 0 path: AsyncResponseImpl.run() calls _thread.run() on the caller thread. + runUnderTrace("parent", () -> new AsyncResponseImpl().invokeSync()); + + assertTraces( + trace( + SORT_BY_START_TIME, + span().root().operationName("parent"), + span().childOfPrevious().operationName("soap.call"))); + } + + @Test + void unrelatedThreadIsNotInstrumented() throws Exception { + // Same construction pattern, but a class the narrow matcher must ignore. + runUnderTrace( + "parent", + () -> { + UnrelatedWorker worker = new UnrelatedWorker(); + worker.start(); + worker.join(); + }); + + // No propagation: the worker's span starts its own trace instead of joining "parent". + assertTraces( + trace(span().root().operationName("parent")), + trace(span().root().operationName("unrelated.work"))); + } + + @Test + void noContextLeakToSubsequentInvocation() throws Exception { + runUnderTrace("parent", () -> new AsyncResponseImpl().invoke()); + // Second invocation runs with no active span: capture is a no-op, so soap.call is its own root. + new AsyncResponseImpl().invoke(); + + assertTraces( + trace( + SORT_BY_START_TIME, + span().root().operationName("parent"), + span().childOfPrevious().operationName("soap.call")), + trace(span().root().operationName("soap.call"))); + } + + @Test + void workerConstructedButNeverRunDoesNotCorruptLaterTraces() throws Exception { + // Constructed under an active span but never run: capture happens but is never activated. + // Guards that the stranded continuation does not mis-attribute a later, unrelated trace. + runUnderTrace("outer", () -> new AsyncResponseImpl()); + runUnderTrace("independent", () -> {}); + + // 'independent' is a clean, standalone root regardless of the stranded continuation. + assertTraces(trace(span().root().operationName("independent"))); + } +} diff --git a/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/gw/internal/xml/ws/AsyncResponseImpl.java b/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/gw/internal/xml/ws/AsyncResponseImpl.java new file mode 100644 index 00000000000..b5ff52050f5 --- /dev/null +++ b/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/gw/internal/xml/ws/AsyncResponseImpl.java @@ -0,0 +1,59 @@ +package gw.internal.xml.ws; + +import datadog.trace.api.Trace; + +/** + * Test double for Guidewire's WSI worker; kept in package {@code gw.internal.xml.ws} so the matcher + * applies. + */ +public class AsyncResponseImpl { + + private final Thread thread; + + public AsyncResponseImpl() { + this.thread = new WebserviceInvocationThread(); + } + + // The boolean only distinguishes this overload from the no-arg constructor; its value is unused. + private AsyncResponseImpl(boolean anonymous) { + this.thread = + new Thread() { + @Override + public void run() { + soapCall(); + } + }; + } + + public static AsyncResponseImpl anonymous() { + return new AsyncResponseImpl(true); + } + + public void invoke() throws InterruptedException { + thread.start(); + thread.join(); + } + + public void invokeSync() { + thread.run(); + } + + @Trace(operationName = "soap.call") + static void soapCall() {} + + // Chained constructor: two frames make capture fire twice, testing the State CAS dedup. + public static final class WebserviceInvocationThread extends Thread { + public WebserviceInvocationThread() { + this("WSI-Invocation"); + } + + private WebserviceInvocationThread(String name) { + super(name); + } + + @Override + public void run() { + soapCall(); + } + } +} diff --git a/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/gw/internal/xml/ws/UnrelatedWorker.java b/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/gw/internal/xml/ws/UnrelatedWorker.java new file mode 100644 index 00000000000..d70037df86a --- /dev/null +++ b/dd-java-agent/instrumentation/guidewire-10.0/src/test/java/gw/internal/xml/ws/UnrelatedWorker.java @@ -0,0 +1,17 @@ +package gw.internal.xml.ws; + +import datadog.trace.api.Trace; + +/** + * Negative control: a Thread subclass the matcher must ignore (not an {@code AsyncResponseImpl$…}). + */ +public class UnrelatedWorker extends Thread { + + @Override + public void run() { + unrelatedWork(); + } + + @Trace(operationName = "unrelated.work") + static void unrelatedWork() {} +} diff --git a/metadata/agent-jar-checks.properties b/metadata/agent-jar-checks.properties index 913a83697b1..5cf2ecc7264 100644 --- a/metadata/agent-jar-checks.properties +++ b/metadata/agent-jar-checks.properties @@ -61,6 +61,7 @@ expected.integrations = IastInstrumentation,\ grpc,\ gson,\ guava,\ + guidewire,\ hazelcast,\ hazelcast_legacy,\ hibernate,\ diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 2d3d77ec355..53326c7542d 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -6281,6 +6281,14 @@ "aliases": ["DD_TRACE_INTEGRATION_GUAVA_ENABLED", "DD_INTEGRATION_GUAVA_ENABLED"] } ], + "DD_TRACE_GUIDEWIRE_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "true", + "aliases": ["DD_TRACE_INTEGRATION_GUIDEWIRE_ENABLED", "DD_INTEGRATION_GUIDEWIRE_ENABLED"] + } + ], "DD_TRACE_HAZELCAST_ENABLED": [ { "version": "A", diff --git a/settings.gradle.kts b/settings.gradle.kts index c2ef7c17958..8d693456591 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -377,6 +377,7 @@ include( ":dd-java-agent:instrumentation:grpc-1.5", ":dd-java-agent:instrumentation:gson-1.6", ":dd-java-agent:instrumentation:guava-10.0", + ":dd-java-agent:instrumentation:guidewire-10.0", ":dd-java-agent:instrumentation:hazelcast:hazelcast-3.6", ":dd-java-agent:instrumentation:hazelcast:hazelcast-3.9", ":dd-java-agent:instrumentation:hazelcast:hazelcast-4.0",