From 9af214a1671746a569d2d3a4517254df0a67f435 Mon Sep 17 00:00:00 2001 From: Mishenevd Date: Mon, 10 Aug 2026 15:33:46 +0200 Subject: [PATCH] Keep instrumenting classes decorated by another agent's synthetic types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the OpenTelemetry agent is loaded before Zen, it injects synthetic supertypes (its VirtualField markers) onto core types such as java.sql.*, jakarta.servlet.* and java.lang.Runnable. Those types have no .class resource, so resolving the hierarchy of any class implementing one threw NoSuchTypeException and the transform was skipped — silently turning off SQL injection, request-context/route and executor instrumentation. Add a lenient type pool that degrades an unresolvable type to an empty interface so resolution completes, and skip OpenTelemetry's own classes. --- .github/workflows/opentel.yml | 49 +++++++++++++ agent/build.gradle | 12 ++++ .../aikido/agent/ByteBuddyInitializer.java | 4 +- .../dev/aikido/agent/LenientPoolStrategy.java | 71 +++++++++++++++++++ .../aikido/agent/LenientPoolStrategyTest.java | 38 ++++++++++ sample-apps/SpringBootPostgres/Makefile | 17 +++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java create mode 100644 agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java diff --git a/.github/workflows/opentel.yml b/.github/workflows/opentel.yml index e9e5aae41..8cd63f3ce 100644 --- a/.github/workflows/opentel.yml +++ b/.github/workflows/opentel.yml @@ -80,3 +80,52 @@ jobs: - name: Run End-to-End tests working-directory: ./ run: tail -f ./sample-apps/JavalinPostgres/output.log & sleep 20 && python end2end/javalin_postgres.py + + opentel_test_spring: + runs-on: ubuntu-latest + needs: build + continue-on-error: true + strategy: + matrix: + java-version: [17, 18, 19, 20, 21] + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: pkg-build + + - name: Set up JDK + uses: actions/setup-java@v2 + with: + java-version: ${{ matrix.java-version }} + distribution: 'adopt' + + - name: Start mock server + working-directory: ./end2end/server + run: | + docker build -t mock_core . + docker run --name mock_core -d -p 5000:5000 mock_core + - name: Start databases + working-directory: ./sample-apps/databases + run: | + docker compose down --volumes + docker compose up --build -d postgres_database + - name: Install Python dependencies + run: python -m pip install -r end2end/requirements.txt + - name: Cleanup application + working-directory: ./sample-apps/SpringBootPostgres + run: chmod +x ./gradlew && make clean + + - name: Build application + working-directory: ./sample-apps/SpringBootPostgres + run: make build + + - name: Start Application (with and without Zen) + working-directory: ./sample-apps/SpringBootPostgres + run: | + nohup make runWithoutZen > output_without_zen.log & sleep 5 + nohup make runWithOpentel > output.log & sleep 5 + + - name: Run End-to-End tests + working-directory: ./ + run: tail -f ./sample-apps/SpringBootPostgres/output.log & sleep 20 && python end2end/spring_boot_postgres.py diff --git a/agent/build.gradle b/agent/build.gradle index 8e4b44536..995c1bca0 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -12,6 +12,18 @@ dependencies { compileOnly 'io.projectreactor.netty:reactor-netty-http:1.2.1' // For Spring Webflux compileOnly 'io.javalin:javalin:6.4.0' compileOnly 'org.springframework:spring-web:5.3.20' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.9.2' +} + +test { + useJUnitPlatform() + // The root `test --tests ` smoke run targets a test in another module; don't fail here on no match. + filter { + setFailOnNoMatchingTests(false) + } } shadowJar { diff --git a/agent/src/main/java/dev/aikido/agent/ByteBuddyInitializer.java b/agent/src/main/java/dev/aikido/agent/ByteBuddyInitializer.java index e22f4caef..82a130132 100644 --- a/agent/src/main/java/dev/aikido/agent/ByteBuddyInitializer.java +++ b/agent/src/main/java/dev/aikido/agent/ByteBuddyInitializer.java @@ -28,6 +28,8 @@ public static AgentBuilder createAgentBuilder(boolean debugMode) { .with(InstrumentedType.Factory.Default.FROZEN) ); + agentBuilder = agentBuilder.with(LenientPoolStrategy.INSTANCE); + // Disables all implicit changes on a class file that Byte Buddy would apply for certain instrumentation's. agentBuilder = agentBuilder.disableClassFormatChanges(); @@ -39,10 +41,10 @@ public static AgentBuilder createAgentBuilder(boolean debugMode) { .with(AgentBuilder.InstallationListener.StreamWriting.toSystemError()); } - // Ignore Byte Buddy and Aikido's internal code: agentBuilder = agentBuilder.ignore( ElementMatchers.nameContains("bytebuddy") .or(ElementMatchers.nameContains("dev.aikido.agent")) + .or(ElementMatchers.nameStartsWith("io.opentelemetry.javaagent")) ); agentBuilder = agentBuilder.with(AgentBuilder.TypeStrategy.Default.DECORATE); diff --git a/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java new file mode 100644 index 000000000..35f126176 --- /dev/null +++ b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java @@ -0,0 +1,71 @@ +package dev.aikido.agent; + +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.description.annotation.AnnotationList; +import net.bytebuddy.description.field.FieldDescription; +import net.bytebuddy.description.field.FieldList; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.method.MethodList; +import net.bytebuddy.description.type.RecordComponentDescription; +import net.bytebuddy.description.type.RecordComponentList; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.pool.TypePool; + +import java.lang.reflect.Modifier; +import java.util.Collections; + +// Another agent (e.g. OpenTelemetry) can add synthetic supertypes with no .class resource to core types; +// the default pool then throws while resolving the hierarchy. Unresolvable types degrade to an empty interface. +public enum LenientPoolStrategy implements AgentBuilder.PoolStrategy { + INSTANCE; + + @Override + public TypePool typePool(ClassFileLocator classFileLocator, ClassLoader classLoader) { + return new LenientPool(new TypePool.CacheProvider.Simple(), classFileLocator, TypePool.Default.ReaderMode.FAST); + } + + @Override + public TypePool typePool(ClassFileLocator classFileLocator, ClassLoader classLoader, String name) { + return typePool(classFileLocator, classLoader); + } + + private static final class LenientPool extends TypePool.Default { + LenientPool(CacheProvider cacheProvider, ClassFileLocator classFileLocator, ReaderMode readerMode) { + super(cacheProvider, classFileLocator, readerMode); + } + + @Override + protected Resolution doDescribe(String name) { + Resolution resolution = super.doDescribe(name); + return resolution.isResolved() ? resolution : new Resolution.Simple(new EmptyStubType(name)); + } + } + + private static final class EmptyStubType extends TypeDescription.Latent { + EmptyStubType(String name) { + super(name, Modifier.PUBLIC | Modifier.ABSTRACT | Modifier.INTERFACE, + TypeDescription.Generic.OBJECT, Collections.emptyList()); + } + + @Override + public MethodList getDeclaredMethods() { + return new MethodList.Empty(); + } + + @Override + public FieldList getDeclaredFields() { + return new FieldList.Empty(); + } + + @Override + public AnnotationList getDeclaredAnnotations() { + return new AnnotationList.Empty(); + } + + @Override + public RecordComponentList getRecordComponents() { + return new RecordComponentList.Empty(); + } + } +} diff --git a/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java b/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java new file mode 100644 index 000000000..b24305be1 --- /dev/null +++ b/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java @@ -0,0 +1,38 @@ +package dev.aikido.agent; + +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.pool.TypePool; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LenientPoolStrategyTest { + private TypePool pool() { + ClassLoader classLoader = getClass().getClassLoader(); + return LenientPoolStrategy.INSTANCE.typePool(ClassFileLocator.ForClassLoader.of(classLoader), classLoader); + } + + @Test + void resolvableTypeIsDescribedNormally() { + TypeDescription type = pool().describe("java.util.ArrayList").resolve(); + + assertEquals("java.util.ArrayList", type.getName()); + assertFalse(type.isInterface()); + assertFalse(type.getDeclaredMethods().isEmpty()); + } + + @Test + void unresolvableTypeDegradesToEmptyInterface() { + TypeDescription type = pool().describe("com.acme.Injected$VirtualField$Absent").resolve(); + + assertEquals("com.acme.Injected$VirtualField$Absent", type.getName()); + assertTrue(type.isInterface()); + assertTrue(type.getDeclaredMethods().isEmpty()); + assertTrue(type.getDeclaredFields().isEmpty()); + assertTrue(type.getInterfaces().isEmpty()); + assertEquals(Object.class.getName(), type.getSuperClass().asErasure().getName()); + } +} diff --git a/sample-apps/SpringBootPostgres/Makefile b/sample-apps/SpringBootPostgres/Makefile index bba15d607..66336fd00 100644 --- a/sample-apps/SpringBootPostgres/Makefile +++ b/sample-apps/SpringBootPostgres/Makefile @@ -37,6 +37,23 @@ runWithDdTrace: build -javaagent:dd-java-agent.jar -Ddd.profiling.enabled=true -Ddd.logs.injection=true -Ddd.service=my-app -Ddd.env=staging -Ddd.version=1.0 \ -javaagent:$(JAVA_AGENT) -jar $(JAR_FILE) --server.port=8080 +# Run with the OpenTelemetry agent loaded before Zen (the order that broke Zen instrumentation). +# The agent runs as a -javaagent on the runner, so pin the version and verify its checksum. +OTEL_VERSION = 2.30.0 +OTEL_SHA256 = 9d6bc2ad8dd8fb7f730984988e57b8ac0a82d81c7b3b8ae795378718733a509d +.PHONY: runWithOpentel +runWithOpentel: build + @echo "Running SpringBootPostgres with OpenTelemetry $(OTEL_VERSION) (first) & Zen (http://localhost:8080)" + wget -O opentelemetry-javaagent.jar 'https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v$(OTEL_VERSION)/opentelemetry-javaagent.jar' + echo "$(OTEL_SHA256) opentelemetry-javaagent.jar" | sha256sum -c - + AIKIDO_LOG_LEVEL="error" \ + AIKIDO_TOKEN="token" \ + AIKIDO_REALTIME_ENDPOINT="http://localhost:5000/realtime" \ + AIKIDO_ENDPOINT="http://localhost:5000" \ + AIKIDO_BLOCK=1 java \ + -javaagent:opentelemetry-javaagent.jar -Dotel.service.name=service -Dotel.traces.exporter=none -Dotel.metrics.exporter=none -Dotel.logs.exporter=none \ + -javaagent:$(JAVA_AGENT) -jar $(JAR_FILE) --server.port=8080 + # Run the application without Zen .PHONY: runWithoutZen runWithoutZen: build