From 293fd123420bd896ec45bfde7ea9df8db862438e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 8 Oct 2026 14:16:04 -0400 Subject: [PATCH 1/4] Latch on JDK 8 AbstractMethodErrors that carry no message ClassLatch attributes an AbstractMethodError to a class by parsing HotSpot's message. JDK 8 throws it with a null message once the call site has dispatched to a class that does implement the method, which in production is nearly always, so handleAbstractMethod never latched on Java 8 and every call for a deficient class kept throwing. When there is no message, attribute the error by checking whether the key class still has a public abstract method of that name, as a concrete class that never implemented an interface method does. A delegating wrapper has a concrete implementation, so an error from its delegate is still not blamed on it. The reflection runs only on the failure path. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/util/ClassLatch.java | 74 ++++++++++++++++--- .../datadog/trace/util/ClassLatchTest.java | 51 +++++++++++++ 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ClassLatch.java b/internal-api/src/main/java/datadog/trace/util/ClassLatch.java index b93ad101f3e..f046dff5f63 100644 --- a/internal-api/src/main/java/datadog/trace/util/ClassLatch.java +++ b/internal-api/src/main/java/datadog/trace/util/ClassLatch.java @@ -3,6 +3,9 @@ import datadog.trace.api.function.Strategy; import datadog.trace.api.function.StrategyConsumer; import datadog.trace.api.function.ThrowingFunction; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; @@ -143,10 +146,11 @@ protected final void unlatch(T target) { /** * Latches the target's key if the error's message names that class as the receiver that lacks - * {@code methodName}. Returns whether it latched. An error that does not name the key, for - * example one thrown inside a wrapper's delegate, is left alone — and so is one that names the - * key but for a different method, for example one the guarded method's own implementation calls - * internally. + * {@code methodName}, or, when the JVM gave no message, if that class still has {@code + * methodName} as an abstract method. Returns whether it latched. An error that does not name the + * key, for example one thrown inside a wrapper's delegate, is left alone — and so is one that + * names the key but for a different method, for example one the guarded method's own + * implementation calls internally. */ protected final boolean latchIfNamed(T target, String methodName, AbstractMethodError error) { if (isNamedIn(error, keyOf(target), methodName)) { @@ -159,10 +163,10 @@ protected final boolean latchIfNamed(T target, String methodName, AbstractMethod /** * For a call to a method that some implementations may lack: returns {@link #fallback} if the * call raises {@link AbstractMethodError} or {@link UnsupportedOperationException}, latching the - * key first in the former case if, and only if, the error names it (see {@link #latchIfNamed}). - * An error that does not name the key still yields the fallback; it just is not latched. An - * unsupported operation names no class, so it is never latched, and is caught on every call. - * Anything else, checked exceptions included, propagates unchanged. + * key first in the former case if, and only if, the error is attributed to it (see {@link + * #latchIfNamed}). An error that is not attributed to the key still yields the fallback; it just + * is not latched. An unsupported operation names no class, so it is never latched, and is caught + * on every call. Anything else, checked exceptions included, propagates unchanged. * *
{@code
    * protected Properties apply(Connection c) throws SQLException {
@@ -236,8 +240,9 @@ protected final R handleNoSuchMethod(T target, @Strategy ThrowingFunction{@code
    * protected Properties apply(Connection c) throws SQLException {
@@ -257,6 +262,49 @@ protected final R handleNoSuchOrAbstractMethod(
     }
   }
 
+  /**
+   * Whether a public method named {@code methodName} is still abstract on {@code type}, as an
+   * interface method that a concrete class never implemented is. This attributes the error to the
+   * class just as a message naming it would: a wrapper that delegates the method has a concrete
+   * implementation, so an error raised by its delegate is not attributed to it. Overloads are not
+   * told apart. Each class is scanned once, whatever the answer, so a class that keeps failing
+   * without being latched does not repeat the reflection.
+   */
+  static boolean lacksImplementation(Class type, String methodName) {
+    for (final String abstractMethod : ABSTRACT_METHOD_NAMES.get(type)) {
+      if (abstractMethod.equals(methodName)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static final String[] NO_NAMES = new String[0];
+
+  /**
+   * Per class, the names of its public methods that are still abstract. Only computed for classes
+   * that failed without a message. The value is a JDK type, like {@link #latched}'s.
+   */
+  private static final ClassValue ABSTRACT_METHOD_NAMES =
+      new ClassValue() {
+        @Override
+        protected String[] computeValue(Class type) {
+          try {
+            int count = 0;
+            final Method[] methods = type.getMethods();
+            final String[] names = new String[methods.length];
+            for (final Method method : methods) {
+              if (Modifier.isAbstract(method.getModifiers())) {
+                names[count++] = method.getName();
+              }
+            }
+            return count == 0 ? NO_NAMES : Arrays.copyOf(names, count);
+          } catch (final SecurityException ignored) {
+            return NO_NAMES; // cannot tell, so never latch
+          }
+        }
+      };
+
   /**
    * Matches the receiver class named by HotSpot's message against a class. Two formats exist:
    *
@@ -271,11 +319,15 @@ protected final R handleNoSuchOrAbstractMethod(
    * method's implementation can call a different, unimplemented method on the very same receiver,
    * raising an error that names the key class but blames a method other than {@code methodName}, so
    * the method name is checked too. An unparseable message never matches.
+   *
+   * 

JDK 8 omits the message once the call site has dispatched to a class that does implement the + * method, which in production is nearly always. Without a message the class itself is checked + * instead: see {@link #lacksImplementation}. */ static boolean isNamedIn(AbstractMethodError e, Class type, String methodName) { final String message = e.getMessage(); if (message == null) { - return false; + return lacksImplementation(type, methodName); } final String name = type.getName(); if (message.startsWith(RECEIVER_PREFIX)) { diff --git a/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java b/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java index c41c439d8ca..ba4dd826e61 100644 --- a/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java @@ -300,12 +300,63 @@ void attributesTheHotSpotMessageFormats() { assertFalse(ClassLatch.isNamedIn(new AbstractMethodError(name), String.class, "getClientInfo")); assertFalse( ClassLatch.isNamedIn(new AbstractMethodError(name + "."), String.class, "getClientInfo")); + // no message: String has no abstract getClientInfo, so the error is not attributed to it assertFalse(ClassLatch.isNamedIn(new AbstractMethodError(), String.class, "getClientInfo")); assertFalse( ClassLatch.isNamedIn( new AbstractMethodError("something else"), String.class, "getClientInfo")); } + /** Stands in for a class that never implemented an interface method it declares. */ + abstract static class Unimplemented { + public abstract String m(); + + public String implemented() { + return "ok"; + } + } + + /** Stands in for a wrapper that implements the method by delegating it. */ + static class Delegating extends Unimplemented { + @Override + public String m() { + return "delegated"; + } + } + + @Test + void attributesAnErrorWithoutAMessageByTheClassItself() { + // JDK 8 gives no message once the call site has seen a class that implements the method + AbstractMethodError noMessage = new AbstractMethodError(); + assertTrue(ClassLatch.isNamedIn(noMessage, Unimplemented.class, "m")); + // the class implements this method, so an error from it must come from elsewhere + assertFalse(ClassLatch.isNamedIn(noMessage, Unimplemented.class, "implemented")); + // a delegating wrapper is not blamed for its delegate's error + assertFalse(ClassLatch.isNamedIn(noMessage, Delegating.class, "m")); + assertFalse(ClassLatch.isNamedIn(noMessage, Unimplemented.class, "missing")); + } + + @Test + void latchIfNamedLatchesOnAnErrorWithoutAMessage() { + final boolean[] result = new boolean[1]; + ClassLatch latch = + new ClassLatch() { + @Override + protected Class keyOf(Object target) { + return Unimplemented.class; + } + + @Override + protected String apply(Object target) { + result[0] = latchIfNamed(target, "m", new AbstractMethodError()); + return "unnamed"; + } + }; + latch.tryApply("x"); + assertTrue(result[0]); + assertTrue(latch.isLatched("x")); + } + @Test void checkedExceptionsPropagateWithoutLatching() { ClassLatch latch = From 8fd897bb540269de1a3c4a60aa0d9022bf27c686 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 8 Oct 2026 16:07:21 -0400 Subject: [PATCH 2/4] Treat an unresolvable signature as unknown when attributing an error Class.getMethods() resolves every public method's signature, so a class with a method that references a type missing from the class path raises NoClassDefFoundError. That escaped from the AbstractMethodError handler and out of tryApply, where before the null-message attribution the fallback was returned. Catch LinkageError alongside SecurityException and cache "cannot tell", so the class is not latched and the fallback is returned as before. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/util/ClassLatch.java | 6 +- .../datadog/trace/util/ClassLatchTest.java | 66 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ClassLatch.java b/internal-api/src/main/java/datadog/trace/util/ClassLatch.java index f046dff5f63..b9c6e41a3bb 100644 --- a/internal-api/src/main/java/datadog/trace/util/ClassLatch.java +++ b/internal-api/src/main/java/datadog/trace/util/ClassLatch.java @@ -299,8 +299,10 @@ protected String[] computeValue(Class type) { } } return count == 0 ? NO_NAMES : Arrays.copyOf(names, count); - } catch (final SecurityException ignored) { - return NO_NAMES; // cannot tell, so never latch + } catch (final SecurityException | LinkageError ignored) { + // cannot tell, so never latch: getMethods() resolves every public signature, and one + // that references a class missing from the class path raises NoClassDefFoundError + return NO_NAMES; } } }; diff --git a/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java b/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java index ba4dd826e61..6dd220f95cc 100644 --- a/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ClassLatchTest.java @@ -6,6 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.sql.SQLException; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; @@ -336,6 +339,69 @@ void attributesAnErrorWithoutAMessageByTheClassItself() { assertFalse(ClassLatch.isNamedIn(noMessage, Unimplemented.class, "missing")); } + /** Stands in for a type that is missing from the class path at run time. */ + public static class Missing {} + + /** A class with a public signature that references {@link Missing}. */ + public static class ReferencesMissing { + public Missing m() { + return null; + } + } + + @Test + void anUnresolvableSignatureMeansCannotTellNotAnEscapingError() throws Exception { + Class type = withoutMissing().loadClass(ReferencesMissing.class.getName()); + // getMethods() resolves every public signature, so the missing return type fails it + assertThrows(NoClassDefFoundError.class, type::getMethods); + + assertFalse(ClassLatch.isNamedIn(new AbstractMethodError(), type, "m")); + // the answer is cached, so a second failure neither rescans nor throws + assertFalse(ClassLatch.isNamedIn(new AbstractMethodError(), type, "m")); + } + + /** Defines {@link ReferencesMissing} itself, and refuses to load {@link Missing}. */ + private static ClassLoader withoutMissing() { + return new ClassLoader(ClassLatchTest.class.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals(Missing.class.getName())) { + throw new ClassNotFoundException(name); + } + if (!name.equals(ReferencesMissing.class.getName())) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + byte[] bytes = classBytes(name); + loaded = defineClass(name, bytes, 0, bytes.length); + } + return loaded; + } + } + }; + } + + private static byte[] classBytes(String name) throws ClassNotFoundException { + try (InputStream in = + ClassLatchTest.class + .getClassLoader() + .getResourceAsStream(name.replace('.', '/') + ".class")) { + if (in == null) { + throw new ClassNotFoundException(name); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + for (int read; (read = in.read(buffer)) != -1; ) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + @Test void latchIfNamedLatchesOnAnErrorWithoutAMessage() { final boolean[] result = new boolean[1]; From aefd9ddbb6ca7c1e17ef84ea64be9d11f13f9302 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 8 Oct 2026 14:43:38 -0400 Subject: [PATCH 3/4] Tolerate servlet responses without Servlet 3.0 header accessors HttpServletExtractAdapter.Response called HttpServletResponse.getHeaderNames() and getHeader(...) on every response. A class compiled against Servlet 2.5 that implements HttpServletResponse itself does not implement them, so the calls threw AbstractMethodError, which escaped as "Failed to decorate span on response" and skipped the response-header callbacks for every response of that class. Guard the accessors with a ClassLatch keyed on the response class. A latched class visits no headers, so the header-done callback still fires. Subclasses of HttpServletResponseWrapper need no special handling: they inherit the container's delegating accessors. Co-Authored-By: Claude Opus 5.5 --- .../servlet3/HttpServletExtractAdapter.java | 47 ++++ .../servlet3/Servlet3Instrumentation.java | 1 + .../HttpServletExtractAdapterTest.java | 229 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java index 6fce09dfdb2..c37615c120a 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java @@ -4,6 +4,7 @@ import static java.util.Collections.enumeration; import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; +import datadog.trace.util.ClassLatch; import java.util.Collection; import java.util.Enumeration; import javax.servlet.http.HttpServletRequest; @@ -40,9 +41,41 @@ String getHeader(HttpServletRequest request, String name) { } } + /** + * Reads response headers through the Servlet 3.0 accessors. A response class compiled against + * Servlet 2.5 that implements {@link HttpServletResponse} itself does not implement them, so + * calling them throws {@link AbstractMethodError}. Such classes are latched and visit no headers. + * Subclasses of {@code HttpServletResponseWrapper} are not affected: they inherit the container's + * delegating implementations. + */ public static final class Response extends HttpServletExtractAdapter { public static final Response GETTER = new Response(); + static final HeaderAccessLatch HEADER_ACCESS = new HeaderAccessLatch(); + + @Override + public void forEachKey(HttpServletResponse carrier, AgentPropagation.KeyClassifier classifier) { + final Collection headerNames = HEADER_ACCESS.tryApply(carrier); + if (headerNames == null) { + return; + } + for (String header : headerNames) { + // only the accessor call is guarded: an AbstractMethodError from the classifier callback + // is not evidence that this response class lacks the accessors + final String value; + try { + value = getHeader(carrier, header); + } catch (AbstractMethodError e) { + // only partly implemented: latch and stop instead of retrying, which would re-emit keys + HEADER_ACCESS.latchIfLacksGetHeader(carrier, e); + return; + } + if (!classifier.accept(header, value)) { + return; + } + } + } + @Override Enumeration getHeaderNames(HttpServletResponse response) { final Collection headerNames = response.getHeaderNames(); @@ -53,5 +86,19 @@ Enumeration getHeaderNames(HttpServletResponse response) { String getHeader(HttpServletResponse response, String name) { return response.getHeader(name); } + + /** Skips response classes that do not implement the Servlet 3.0 header accessors. */ + static final class HeaderAccessLatch + extends ClassLatch, RuntimeException> { + @Override + protected Collection apply(HttpServletResponse response) { + return handleAbstractMethod( + response, "getHeaderNames", HttpServletResponse::getHeaderNames); + } + + void latchIfLacksGetHeader(HttpServletResponse response, AbstractMethodError error) { + latchIfNamed(response, "getHeader", error); + } + } } } diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java index 4835d9d2b0b..ac5f52347df 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java @@ -49,6 +49,7 @@ public String[] helperClassNames() { packageName + ".HttpServletExtractAdapter", packageName + ".HttpServletExtractAdapter$Request", packageName + ".HttpServletExtractAdapter$Response", + packageName + ".HttpServletExtractAdapter$Response$HeaderAccessLatch", packageName + ".Servlet3Decorator", packageName + ".ServletRequestURIAdapter", packageName + ".FinishAsyncDispatchListener", diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java new file mode 100644 index 00000000000..a8bca39edc4 --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java @@ -0,0 +1,229 @@ +package datadog.trace.instrumentation.servlet3; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static net.bytebuddy.jar.asm.Opcodes.ACC_PUBLIC; +import static net.bytebuddy.jar.asm.Opcodes.ALOAD; +import static net.bytebuddy.jar.asm.Opcodes.ARETURN; +import static net.bytebuddy.jar.asm.Opcodes.INVOKESPECIAL; +import static net.bytebuddy.jar.asm.Opcodes.INVOKESTATIC; +import static net.bytebuddy.jar.asm.Opcodes.RETURN; +import static net.bytebuddy.jar.asm.Opcodes.V1_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import net.bytebuddy.jar.asm.ClassWriter; +import net.bytebuddy.jar.asm.MethodVisitor; +import org.junit.jupiter.api.Test; + +class HttpServletExtractAdapterTest { + + private static final HttpServletExtractAdapter.Response GETTER = + HttpServletExtractAdapter.Response.GETTER; + + @Test + void readsHeadersFromSupportedResponse() { + List seen = new ArrayList<>(); + + GETTER.forEachKey(supportedResponse(), collectInto(seen)); + + assertEquals(asList("x-a=1", "x-b=2"), seen); + } + + @Test + void skipsResponseClassWithoutHeaderAccessors() throws Exception { + // sanity check: calling the Servlet 3.0 method on this class really fails + HttpServletResponse old = newOldResponse("OldResponse"); + assertThrows(AbstractMethodError.class, old::getHeaderNames); + List seen = new ArrayList<>(); + + // the first response of the class trips the failure path + GETTER.forEachKey(old, collectInto(seen)); + // later responses of the same class take the remembered path + GETTER.forEachKey(old, collectInto(seen)); + + assertEquals(emptyList(), seen); + } + + @Test + void healthyResponsesStillWorkAfterAFailure() throws Exception { + // tests can run in any order, so trip the failure path here to cover healthy responses after it + List failed = new ArrayList<>(); + GETTER.forEachKey(newOldResponse("OldResponseTwo"), collectInto(failed)); + assertEquals(emptyList(), failed); + List seen = new ArrayList<>(); + + GETTER.forEachKey(supportedResponse(), collectInto(seen)); + + assertEquals(asList("x-a=1", "x-b=2"), seen); + } + + @Test + void callbackFailureDoesNotMarkResponseClassUnsupported() { + // an AbstractMethodError raised by the callback says nothing about the response class + assertThrows( + AbstractMethodError.class, + () -> + GETTER.forEachKey( + supportedResponse(), + (key, value) -> { + throw new AbstractMethodError(); + })); + List seen = new ArrayList<>(); + + GETTER.forEachKey(supportedResponse(), collectInto(seen)); + + assertEquals(asList("x-a=1", "x-b=2"), seen); + } + + @Test + void latchesResponseClassWithoutHeaderAccessors() throws Exception { + // warm the accessor call sites with a working class first: JDK 8 then throws the + // AbstractMethodError without a message, as it does in production + GETTER.forEachKey(supportedResponse(), collectInto(new ArrayList<>())); + HttpServletResponse old = newOldResponse("OldResponseLatched"); + assertFalse(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(old)); + + GETTER.forEachKey(old, collectInto(new ArrayList<>())); + + assertTrue(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(old)); + assertFalse(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(supportedResponse())); + } + + @Test + void latchesResponseClassMissingOnlyGetHeader() throws Exception { + // warm the accessor call sites with a working class first: JDK 8 then throws the + // AbstractMethodError without a message, as it does in production + GETTER.forEachKey(supportedResponse(), collectInto(new ArrayList<>())); + // not producible by javac, but bytecode generators can implement one accessor and not the other + HttpServletResponse partial = newPartialResponse("PartialResponse"); + List seen = new ArrayList<>(); + + GETTER.forEachKey(partial, collectInto(seen)); + GETTER.forEachKey(partial, collectInto(seen)); + + assertEquals(emptyList(), seen); + assertTrue(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(partial)); + } + + @Test + void oldWrapperSubclassInheritsHeaderAccessors() throws Exception { + // a wrapper compiled against Servlet 2.5 inherits the container's delegating accessors, which + // is why these classes need no unwrapping + HttpServletResponse oldWrapper = newOldWrapper("OldWrapper", supportedResponse()); + List seen = new ArrayList<>(); + + GETTER.forEachKey(oldWrapper, collectInto(seen)); + + assertEquals(asList("x-a=1", "x-b=2"), seen); + assertFalse(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(oldWrapper)); + } + + private static HttpServletResponse supportedResponse() { + return (HttpServletResponse) + Proxy.newProxyInstance( + HttpServletResponse.class.getClassLoader(), + new Class[] {HttpServletResponse.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "getHeaderNames": + return asList("x-a", "x-b"); + case "getHeader": + return "x-a".equals(args[0]) ? "1" : "2"; + default: + return null; + } + }); + } + + private static AgentPropagation.KeyClassifier collectInto(List seen) { + return (key, value) -> { + seen.add(key + "=" + value); + return true; + }; + } + + /** Defines a class that implements {@link HttpServletResponse} but none of its methods. */ + private static HttpServletResponse newOldResponse(String name) throws Exception { + return (HttpServletResponse) + define(name, "java/lang/Object", "()V", true, false).getConstructor().newInstance(); + } + + /** Like {@link #newOldResponse}, but implements {@code getHeaderNames} (one name) only. */ + private static HttpServletResponse newPartialResponse(String name) throws Exception { + return (HttpServletResponse) + define(name, "java/lang/Object", "()V", true, true).getConstructor().newInstance(); + } + + /** A {@code HttpServletResponseWrapper} subclass that, like 2.5 code, overrides nothing. */ + private static HttpServletResponse newOldWrapper(String name, HttpServletResponse delegate) + throws Exception { + return (HttpServletResponse) + define( + name, + "javax/servlet/http/HttpServletResponseWrapper", + "(Ljavax/servlet/http/HttpServletResponse;)V", + false, + false) + .getConstructor(HttpServletResponse.class) + .newInstance(delegate); + } + + /** The constructor just forwards its (optional) argument to the same-shaped super constructor. */ + private static Class define( + String name, + String superName, + String ctorDesc, + boolean implementsResponse, + boolean withHeaderNames) + throws Exception { + String internalName = "test/" + name; + ClassWriter writer = new ClassWriter(0); + writer.visit( + V1_8, + ACC_PUBLIC, + internalName, + null, + superName, + implementsResponse ? new String[] {"javax/servlet/http/HttpServletResponse"} : null); + MethodVisitor init = writer.visitMethod(ACC_PUBLIC, "", ctorDesc, null, null); + init.visitCode(); + init.visitVarInsn(ALOAD, 0); + if (!"()V".equals(ctorDesc)) { + init.visitVarInsn(ALOAD, 1); + } + init.visitMethodInsn(INVOKESPECIAL, superName, "", ctorDesc, false); + init.visitInsn(RETURN); + init.visitMaxs(2, 2); + init.visitEnd(); + if (withHeaderNames) { + MethodVisitor names = + writer.visitMethod(ACC_PUBLIC, "getHeaderNames", "()Ljava/util/Collection;", null, null); + names.visitCode(); + names.visitLdcInsn("x-a"); + names.visitMethodInsn( + INVOKESTATIC, + "java/util/Collections", + "singletonList", + "(Ljava/lang/Object;)Ljava/util/List;", + false); + names.visitInsn(ARETURN); + names.visitMaxs(1, 1); + names.visitEnd(); + } + writer.visitEnd(); + byte[] bytes = writer.toByteArray(); + return new ClassLoader(HttpServletExtractAdapterTest.class.getClassLoader()) { + Class define() { + return defineClass("test." + name, bytes, 0, bytes.length); + } + }.define(); + } +} From bea559a1b81533cb69df0a88c8393e53d306491f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 8 Oct 2026 15:52:38 -0400 Subject: [PATCH 4/4] Rename the header latch and drop unreachable accessor overrides Response overrides forEachKey, so the base class's getHeaderNames/getHeader contract only served Request; move it there. Co-Authored-By: Claude Opus 5.5 --- .../servlet3/HttpServletExtractAdapter.java | 55 +++++-------------- .../HttpServletExtractAdapterTest.java | 28 ++++++---- 2 files changed, 32 insertions(+), 51 deletions(-) diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java index c37615c120a..621add5bbc4 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapter.java @@ -1,8 +1,5 @@ package datadog.trace.instrumentation.servlet3; -import static java.util.Collections.emptyEnumeration; -import static java.util.Collections.enumeration; - import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; import datadog.trace.util.ClassLatch; import java.util.Collection; @@ -11,33 +8,22 @@ import javax.servlet.http.HttpServletResponse; public abstract class HttpServletExtractAdapter implements AgentPropagation.ContextVisitor { - abstract Enumeration getHeaderNames(T t); - - abstract String getHeader(T t, String name); - - @Override - public void forEachKey(T carrier, AgentPropagation.KeyClassifier classifier) { - Enumeration headerNames = getHeaderNames(carrier); - while (headerNames.hasMoreElements()) { - String header = headerNames.nextElement(); - if (!classifier.accept(header, getHeader(carrier, header))) { - break; - } - } - } public static final class Request extends HttpServletExtractAdapter { public static final Request GETTER = new Request(); @Override - Enumeration getHeaderNames(HttpServletRequest request) { - final Enumeration ret = request.getHeaderNames(); - return ret != null ? ret : emptyEnumeration(); - } - - @Override - String getHeader(HttpServletRequest request, String name) { - return request.getHeader(name); + public void forEachKey(HttpServletRequest carrier, AgentPropagation.KeyClassifier classifier) { + final Enumeration headerNames = carrier.getHeaderNames(); + if (headerNames == null) { + return; + } + while (headerNames.hasMoreElements()) { + String header = headerNames.nextElement(); + if (!classifier.accept(header, carrier.getHeader(header))) { + break; + } + } } } @@ -51,11 +37,11 @@ String getHeader(HttpServletRequest request, String name) { public static final class Response extends HttpServletExtractAdapter { public static final Response GETTER = new Response(); - static final HeaderAccessLatch HEADER_ACCESS = new HeaderAccessLatch(); + static final HeaderAccessLatch HEADER_LATCH = new HeaderAccessLatch(); @Override public void forEachKey(HttpServletResponse carrier, AgentPropagation.KeyClassifier classifier) { - final Collection headerNames = HEADER_ACCESS.tryApply(carrier); + final Collection headerNames = HEADER_LATCH.tryApply(carrier); if (headerNames == null) { return; } @@ -64,10 +50,10 @@ public void forEachKey(HttpServletResponse carrier, AgentPropagation.KeyClassifi // is not evidence that this response class lacks the accessors final String value; try { - value = getHeader(carrier, header); + value = carrier.getHeader(header); } catch (AbstractMethodError e) { // only partly implemented: latch and stop instead of retrying, which would re-emit keys - HEADER_ACCESS.latchIfLacksGetHeader(carrier, e); + HEADER_LATCH.latchIfLacksGetHeader(carrier, e); return; } if (!classifier.accept(header, value)) { @@ -76,17 +62,6 @@ public void forEachKey(HttpServletResponse carrier, AgentPropagation.KeyClassifi } } - @Override - Enumeration getHeaderNames(HttpServletResponse response) { - final Collection headerNames = response.getHeaderNames(); - return headerNames != null ? enumeration(headerNames) : emptyEnumeration(); - } - - @Override - String getHeader(HttpServletResponse response, String name) { - return response.getHeader(name); - } - /** Skips response classes that do not implement the Servlet 3.0 header accessors. */ static final class HeaderAccessLatch extends ClassLatch, RuntimeException> { diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java index a8bca39edc4..39dc592d4f6 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/test/java/datadog/trace/instrumentation/servlet3/HttpServletExtractAdapterTest.java @@ -27,6 +27,8 @@ class HttpServletExtractAdapterTest { private static final HttpServletExtractAdapter.Response GETTER = HttpServletExtractAdapter.Response.GETTER; + private static final HttpServletExtractAdapter.Response.HeaderAccessLatch HEADER_LATCH = + HttpServletExtractAdapter.Response.HEADER_LATCH; @Test void readsHeadersFromSupportedResponse() { @@ -85,23 +87,19 @@ void callbackFailureDoesNotMarkResponseClassUnsupported() { @Test void latchesResponseClassWithoutHeaderAccessors() throws Exception { - // warm the accessor call sites with a working class first: JDK 8 then throws the - // AbstractMethodError without a message, as it does in production - GETTER.forEachKey(supportedResponse(), collectInto(new ArrayList<>())); + warmAccessorCallSites(); HttpServletResponse old = newOldResponse("OldResponseLatched"); - assertFalse(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(old)); + assertFalse(HEADER_LATCH.isLatched(old)); GETTER.forEachKey(old, collectInto(new ArrayList<>())); - assertTrue(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(old)); - assertFalse(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(supportedResponse())); + assertTrue(HEADER_LATCH.isLatched(old)); + assertFalse(HEADER_LATCH.isLatched(supportedResponse())); } @Test void latchesResponseClassMissingOnlyGetHeader() throws Exception { - // warm the accessor call sites with a working class first: JDK 8 then throws the - // AbstractMethodError without a message, as it does in production - GETTER.forEachKey(supportedResponse(), collectInto(new ArrayList<>())); + warmAccessorCallSites(); // not producible by javac, but bytecode generators can implement one accessor and not the other HttpServletResponse partial = newPartialResponse("PartialResponse"); List seen = new ArrayList<>(); @@ -110,7 +108,7 @@ void latchesResponseClassMissingOnlyGetHeader() throws Exception { GETTER.forEachKey(partial, collectInto(seen)); assertEquals(emptyList(), seen); - assertTrue(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(partial)); + assertTrue(HEADER_LATCH.isLatched(partial)); } @Test @@ -123,7 +121,15 @@ void oldWrapperSubclassInheritsHeaderAccessors() throws Exception { GETTER.forEachKey(oldWrapper, collectInto(seen)); assertEquals(asList("x-a=1", "x-b=2"), seen); - assertFalse(HttpServletExtractAdapter.Response.HEADER_ACCESS.isLatched(oldWrapper)); + assertFalse(HEADER_LATCH.isLatched(oldWrapper)); + } + + /** + * Calls the accessors on a working class first. JDK 8 then throws the AbstractMethodError without + * a message, as it does in production, where the call sites have seen healthy responses. + */ + private static void warmAccessorCallSites() { + GETTER.forEachKey(supportedResponse(), collectInto(new ArrayList<>())); } private static HttpServletResponse supportedResponse() {