From f3208c11253e804fe622f912563db5d67ac1108c Mon Sep 17 00:00:00 2001 From: William Conti Date: Wed, 11 Feb 2026 16:58:26 -0500 Subject: [PATCH] feat(instrumentation): add Feign HTTP client instrumentation Add instrumentation for OpenFeign (feign-core) HTTP client library. Covers both sync (Client) and async (AsyncClient) execution paths with distributed tracing context propagation. Files: - FeignClientInstrumentation: ByteBuddy type/method matchers for Client.execute - FeignAsyncClientInstrumentation: Matchers for AsyncClient.execute - FeignClientAdvice: Sync span lifecycle (enter/exit) - FeignAsyncClientAdvice: Async span lifecycle via CompletableFuture - FeignDecorator: HttpClientDecorator with operation names and tags - FeignHeadersInjectAdapter: CarrierSetter for trace context propagation - FeignClientTest: Spock tests using HttpClientTest base (70 tests pass) Generated by APM Instrumentation Toolkit (anubis) Co-Authored-By: Claude Opus 4.6 --- .../instrumentation/feign-core/build.gradle | 21 +++++ .../feign/FeignAsyncClientAdvice.java | 85 +++++++++++++++++++ .../FeignAsyncClientInstrumentation.java | 50 +++++++++++ .../feign/FeignClientAdvice.java | 56 ++++++++++++ .../feign/FeignClientInstrumentation.java | 50 +++++++++++ .../instrumentation/feign/FeignDecorator.java | 80 +++++++++++++++++ .../feign/FeignHeadersInjectAdapter.java | 17 ++++ .../feign/FeignClientTest.groovy | 81 ++++++++++++++++++ settings.gradle.kts | 1 + 9 files changed, 441 insertions(+) create mode 100644 dd-java-agent/instrumentation/feign-core/build.gradle create mode 100644 dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignAsyncClientAdvice.java create mode 100644 dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignAsyncClientInstrumentation.java create mode 100644 dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignClientAdvice.java create mode 100644 dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignClientInstrumentation.java create mode 100644 dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignDecorator.java create mode 100644 dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignHeadersInjectAdapter.java create mode 100644 dd-java-agent/instrumentation/feign-core/src/test/groovy/datadog/trace/instrumentation/feign/FeignClientTest.groovy diff --git a/dd-java-agent/instrumentation/feign-core/build.gradle b/dd-java-agent/instrumentation/feign-core/build.gradle new file mode 100644 index 00000000000..aaa90ba4de9 --- /dev/null +++ b/dd-java-agent/instrumentation/feign-core/build.gradle @@ -0,0 +1,21 @@ +muzzle { + pass { + group = "io.github.openfeign" + module = "feign-core" + versions = "[10.0.0,)" + assertInverse = true + } +} + +apply from: "$rootDir/gradle/java.gradle" + +addTestSuiteForDir('latestDepTest', 'test') + +dependencies { + compileOnly group: 'io.github.openfeign', name: 'feign-core', version: '10.0.0' + + testImplementation group: 'io.github.openfeign', name: 'feign-core', version: '10.0.0' + testImplementation project(':dd-java-agent:testing') + + latestDepTestImplementation group: 'io.github.openfeign', name: 'feign-core', version: '+' +} diff --git a/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignAsyncClientAdvice.java b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignAsyncClientAdvice.java new file mode 100644 index 00000000000..420c1162bfc --- /dev/null +++ b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignAsyncClientAdvice.java @@ -0,0 +1,85 @@ +package datadog.trace.instrumentation.feign; + +import static datadog.context.Context.current; +import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.activateSpan; +import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.startSpan; +import static datadog.trace.instrumentation.feign.FeignDecorator.DECORATE; +import static datadog.trace.instrumentation.feign.FeignDecorator.FEIGN_REQUEST; +import static datadog.trace.instrumentation.feign.FeignHeadersInjectAdapter.SETTER; + +import datadog.trace.bootstrap.CallDepthThreadLocalMap; +import datadog.trace.bootstrap.instrumentation.api.AgentScope; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import feign.Client; +import feign.Request; +import feign.Response; +import java.util.concurrent.CompletableFuture; +import net.bytebuddy.asm.Advice; + +public class FeignAsyncClientAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + public static AgentScope onEnter(@Advice.Argument(0) final Request request) { + final int callDepth = CallDepthThreadLocalMap.incrementCallDepth(Client.class); + if (callDepth > 0) { + return null; + } + + final AgentSpan span = startSpan(FEIGN_REQUEST); + DECORATE.afterStart(span); + DECORATE.onRequest(span, request); + DECORATE.injectContext(current().with(span), request, SETTER); + + return activateSpan(span); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void onExit( + @Advice.Enter final AgentScope scope, + @Advice.Return final CompletableFuture future, + @Advice.Thrown final Throwable throwable) { + if (scope == null) { + return; + } + final AgentSpan span = scope.span(); + + if (throwable != null) { + try { + DECORATE.onError(span, throwable); + DECORATE.beforeFinish(span); + } finally { + scope.close(); + span.finish(); + CallDepthThreadLocalMap.reset(Client.class); + } + return; + } + + scope.close(); + + if (future != null) { + future.whenComplete( + (response, error) -> { + try { + if (response != null) { + DECORATE.onResponse(span, response); + } + if (error != null) { + DECORATE.onError(span, error); + } + DECORATE.beforeFinish(span); + } finally { + span.finish(); + CallDepthThreadLocalMap.reset(Client.class); + } + }); + } else { + try { + DECORATE.beforeFinish(span); + } finally { + span.finish(); + CallDepthThreadLocalMap.reset(Client.class); + } + } + } +} diff --git a/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignAsyncClientInstrumentation.java b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignAsyncClientInstrumentation.java new file mode 100644 index 00000000000..d7864ccba80 --- /dev/null +++ b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignAsyncClientInstrumentation.java @@ -0,0 +1,50 @@ +package datadog.trace.instrumentation.feign; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.isPublic; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +@AutoService(InstrumenterModule.class) +public class FeignAsyncClientInstrumentation extends InstrumenterModule.Tracing + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + + public FeignAsyncClientInstrumentation() { + super("feign"); + } + + @Override + public String hierarchyMarkerType() { + return "feign.AsyncClient"; + } + + @Override + public ElementMatcher hierarchyMatcher() { + return implementsInterface(named("feign.AsyncClient")); + } + + @Override + public String[] helperClassNames() { + return new String[] { + packageName + ".FeignDecorator", + packageName + ".FeignHeadersInjectAdapter", + }; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + isMethod() + .and(isPublic()) + .and(named("execute")) + .and(takesArgument(0, named("feign.Request"))), + packageName + ".FeignAsyncClientAdvice"); + } +} diff --git a/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignClientAdvice.java b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignClientAdvice.java new file mode 100644 index 00000000000..ff25dac0ebd --- /dev/null +++ b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignClientAdvice.java @@ -0,0 +1,56 @@ +package datadog.trace.instrumentation.feign; + +import static datadog.context.Context.current; +import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.activateSpan; +import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.startSpan; +import static datadog.trace.instrumentation.feign.FeignDecorator.DECORATE; +import static datadog.trace.instrumentation.feign.FeignDecorator.FEIGN_REQUEST; +import static datadog.trace.instrumentation.feign.FeignHeadersInjectAdapter.SETTER; + +import datadog.trace.bootstrap.CallDepthThreadLocalMap; +import datadog.trace.bootstrap.instrumentation.api.AgentScope; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import feign.Client; +import feign.Request; +import feign.Response; +import net.bytebuddy.asm.Advice; + +public class FeignClientAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + public static AgentScope onEnter(@Advice.Argument(0) final Request request) { + final int callDepth = CallDepthThreadLocalMap.incrementCallDepth(Client.class); + if (callDepth > 0) { + return null; + } + + final AgentSpan span = startSpan(FEIGN_REQUEST); + DECORATE.afterStart(span); + DECORATE.onRequest(span, request); + DECORATE.injectContext(current().with(span), request, SETTER); + + return activateSpan(span); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void onExit( + @Advice.Enter final AgentScope scope, + @Advice.Return final Response response, + @Advice.Thrown final Throwable throwable) { + if (scope == null) { + return; + } + final AgentSpan span = scope.span(); + try { + if (response != null) { + DECORATE.onResponse(span, response); + } + DECORATE.onError(span, throwable); + DECORATE.beforeFinish(span); + } finally { + scope.close(); + span.finish(); + CallDepthThreadLocalMap.reset(Client.class); + } + } +} diff --git a/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignClientInstrumentation.java b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignClientInstrumentation.java new file mode 100644 index 00000000000..d51172250ad --- /dev/null +++ b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignClientInstrumentation.java @@ -0,0 +1,50 @@ +package datadog.trace.instrumentation.feign; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.isPublic; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +@AutoService(InstrumenterModule.class) +public class FeignClientInstrumentation extends InstrumenterModule.Tracing + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + + public FeignClientInstrumentation() { + super("feign"); + } + + @Override + public String hierarchyMarkerType() { + return "feign.Client"; + } + + @Override + public ElementMatcher hierarchyMatcher() { + return implementsInterface(named("feign.Client")); + } + + @Override + public String[] helperClassNames() { + return new String[] { + packageName + ".FeignDecorator", + packageName + ".FeignHeadersInjectAdapter", + }; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + isMethod() + .and(isPublic()) + .and(named("execute")) + .and(takesArgument(0, named("feign.Request"))), + packageName + ".FeignClientAdvice"); + } +} diff --git a/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignDecorator.java b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignDecorator.java new file mode 100644 index 00000000000..a5f1c26afb8 --- /dev/null +++ b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignDecorator.java @@ -0,0 +1,80 @@ +package datadog.trace.instrumentation.feign; + +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import datadog.trace.bootstrap.instrumentation.decorator.HttpClientDecorator; +import feign.Request; +import feign.Response; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.Map; + +public class FeignDecorator extends HttpClientDecorator { + + public static final CharSequence FEIGN = UTF8BytesString.create("feign"); + public static final FeignDecorator DECORATE = new FeignDecorator(); + public static final CharSequence FEIGN_REQUEST = + UTF8BytesString.create(DECORATE.operationName()); + + @Override + protected String[] instrumentationNames() { + return new String[] {"feign"}; + } + + @Override + protected String service() { + return null; + } + + @Override + protected CharSequence component() { + return FEIGN; + } + + @Override + protected String method(final Request request) { + return request.httpMethod().name(); + } + + @Override + protected URI url(final Request request) { + try { + return new URI(request.url()); + } catch (URISyntaxException e) { + return null; + } + } + + @Override + protected int status(final Response response) { + return response.status(); + } + + @Override + protected String getRequestHeader(Request request, String headerName) { + Map> headers = request.headers(); + if (headers != null) { + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(headerName)) { + Collection values = entry.getValue(); + if (values != null && !values.isEmpty()) { + return values.iterator().next(); + } + } + } + } + return null; + } + + @Override + protected String getResponseHeader(Response response, String headerName) { + Map> headers = response.headers(); + if (headers != null) { + Collection values = headers.get(headerName); + if (values != null && !values.isEmpty()) { + return values.iterator().next(); + } + } + return null; + } +} diff --git a/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignHeadersInjectAdapter.java b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignHeadersInjectAdapter.java new file mode 100644 index 00000000000..46a4621d627 --- /dev/null +++ b/dd-java-agent/instrumentation/feign-core/src/main/java/datadog/trace/instrumentation/feign/FeignHeadersInjectAdapter.java @@ -0,0 +1,17 @@ +package datadog.trace.instrumentation.feign; + +import datadog.context.propagation.CarrierSetter; +import feign.Request; +import java.util.Collections; +import javax.annotation.ParametersAreNonnullByDefault; + +@ParametersAreNonnullByDefault +public class FeignHeadersInjectAdapter implements CarrierSetter { + + public static final FeignHeadersInjectAdapter SETTER = new FeignHeadersInjectAdapter(); + + @Override + public void set(final Request carrier, final String key, final String value) { + carrier.headers().put(key, Collections.singletonList(value)); + } +} diff --git a/dd-java-agent/instrumentation/feign-core/src/test/groovy/datadog/trace/instrumentation/feign/FeignClientTest.groovy b/dd-java-agent/instrumentation/feign-core/src/test/groovy/datadog/trace/instrumentation/feign/FeignClientTest.groovy new file mode 100644 index 00000000000..9d0d50ead00 --- /dev/null +++ b/dd-java-agent/instrumentation/feign-core/src/test/groovy/datadog/trace/instrumentation/feign/FeignClientTest.groovy @@ -0,0 +1,81 @@ +package datadog.trace.instrumentation.feign + +import datadog.trace.agent.test.base.HttpClientTest +import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions +import feign.Client +import feign.Request +import feign.Response +import spock.lang.Timeout + +import java.nio.charset.StandardCharsets + +abstract class FeignClientTest extends HttpClientTest { + + def client = new Client.Default(null, null) + + @Override + int doRequest(String method, URI uri, Map headers, String body, Closure callback) { + Map> headerMap = new LinkedHashMap<>() + headers.each { k, v -> + headerMap.put(k, [v]) + } + + byte[] bodyBytes = body ? body.getBytes(StandardCharsets.UTF_8) : null + + def request = Request.create( + Request.HttpMethod.valueOf(method), + uri.toString(), + headerMap, + bodyBytes, + StandardCharsets.UTF_8 + ) + + def options = new Request.Options(CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS) + def response = client.execute(request, options) + + callback?.call() + return response.status() + } + + @Override + CharSequence component() { + return FeignDecorator.DECORATE.component() + } + + @Override + boolean testRedirects() { + false + } + + @Override + boolean testProxy() { + false + } + + @Override + boolean testCallbackWithParent() { + false + } +} + +@Timeout(10) +class FeignClientV0ForkedTest extends FeignClientTest { + @Override + int version() { + return 0 + } + + @Override + String service() { + return null + } + + @Override + String operation() { + return "http.request" + } +} + +@Timeout(10) +class FeignClientV1ForkedTest extends FeignClientTest implements TestingGenericHttpNamingConventions.ClientV1 { +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0d9b113dfdd..994c9e269bb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -339,6 +339,7 @@ include( ":dd-java-agent:instrumentation:elasticsearch:elasticsearch-transport:elasticsearch-transport-7.3", ":dd-java-agent:instrumentation:elasticsearch:elasticsearch-transport:elasticsearch-transport-common", ":dd-java-agent:instrumentation:elasticsearch:elasticsearch-common", + ":dd-java-agent:instrumentation:feign-core", ":dd-java-agent:instrumentation:finatra-2.9", ":dd-java-agent:instrumentation:freemarker:freemarker-2.3.24", ":dd-java-agent:instrumentation:freemarker:freemarker-2.3.9",