diff --git a/settings.gradle.kts b/settings.gradle.kts index c175394c2dd4..b133e4277892 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -156,6 +156,8 @@ include(":smoke-tests:images:servlet") include(":smoke-tests:images:servlet:servlet-3.0") include(":smoke-tests:images:servlet:servlet-5.0") include(":smoke-tests:images:spring-boot") +include(":smoke-tests:extensions:testapp") +include(":smoke-tests:extensions:extension") include(":smoke-tests-otel-starter:spring-smoke-testing") include(":smoke-tests-otel-starter:spring-boot-2") diff --git a/smoke-tests/README.md b/smoke-tests/README.md index 9d4c2426efb1..de1c44f2ac4e 100644 --- a/smoke-tests/README.md +++ b/smoke-tests/README.md @@ -2,5 +2,8 @@ Assert that various applications will start up with the JavaAgent without any obvious ill effects. -Each subproject underneath `smoke-tests` produces one or more docker images containing some application +Each subproject underneath `smoke-tests/images` produces one or more docker images containing some application under the test. Various tests in the main module then use them to run the appropriate tests. + +The `smoke-tests/extensions` folder contains a test application and packaged instrumentation(s) extension to +test compatibility with existing user-built extensions. diff --git a/smoke-tests/build.gradle.kts b/smoke-tests/build.gradle.kts index 39ff7b05602f..e5c3655a472d 100644 --- a/smoke-tests/build.gradle.kts +++ b/smoke-tests/build.gradle.kts @@ -77,8 +77,20 @@ tasks { .withPropertyName("javaagent") .withNormalizer(ClasspathNormalizer::class) + val extensionTask = project(":smoke-tests:extensions:extension").tasks.named("jar") + val extensionJarPath = extensionTask.flatMap { it.archiveFile } + + val extensionTestAppTask = project(":smoke-tests:extensions:testapp").tasks.named("jar") + val extensionTestAppJarPath = extensionTestAppTask.flatMap { it.archiveFile } + + dependsOn(shadowTask, extensionTestAppTask, extensionTask) + doFirst { - jvmArgs("-Dio.opentelemetry.smoketest.agent.shadowJar.path=${agentJarPath.get()}") + jvmArgs( + "-Dio.opentelemetry.smoketest.agent.shadowJar.path=${agentJarPath.get()}", + "-Dio.opentelemetry.smoketest.extension.path=${extensionJarPath.get()}", + "-Dio.opentelemetry.smoketest.extension.testapp.path=${extensionTestAppJarPath.get()}" + ) } } } diff --git a/smoke-tests/extensions/extension/build.gradle.kts b/smoke-tests/extensions/extension/build.gradle.kts new file mode 100644 index 000000000000..6d9067e532fc --- /dev/null +++ b/smoke-tests/extensions/extension/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("otel.java-conventions") + id("io.opentelemetry.instrumentation.javaagent-instrumentation") +} + +dependencies { + compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") + compileOnly("io.opentelemetry.instrumentation:opentelemetry-instrumentation-api") + compileOnly("io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator") + compileOnly("io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api") + + compileOnly("com.google.auto.service:auto-service") + compileOnly("com.google.auto.service:auto-service-annotations") + + annotationProcessor("com.google.auto.service:auto-service") + + // Used by byte-buddy but not brought in as a transitive dependency + compileOnly("com.google.code.findbugs:annotations") +} diff --git a/smoke-tests/extensions/extension/src/main/java/io/opentelemetry/smoketest/extensions/inlined/SmokeInlinedInstrumentation.java b/smoke-tests/extensions/extension/src/main/java/io/opentelemetry/smoketest/extensions/inlined/SmokeInlinedInstrumentation.java new file mode 100644 index 000000000000..92fb56ffb80b --- /dev/null +++ b/smoke-tests/extensions/extension/src/main/java/io/opentelemetry/smoketest/extensions/inlined/SmokeInlinedInstrumentation.java @@ -0,0 +1,104 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.smoketest.extensions.inlined; + +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.returns; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import io.opentelemetry.instrumentation.api.util.VirtualField; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import java.util.Arrays; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +public class SmokeInlinedInstrumentation implements TypeInstrumentation { + + @Override + public ElementMatcher typeMatcher() { + return named("io.opentelemetry.smoketest.extensions.app.AppMain"); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + named("returnValue").and(takesArgument(0, int.class)), + this.getClass().getName() + "$ModifyReturnValueAdvice"); + transformer.applyAdviceToMethod( + named("methodArguments").and(takesArgument(0, int.class)), + this.getClass().getName() + "$ModifyArgumentsAdvice"); + transformer.applyAdviceToMethod( + named("setVirtualFieldValue") + .and(takesArgument(0, Object.class)) + .and(takesArgument(1, Integer.class)), + this.getClass().getName() + "$VirtualFieldSetAdvice"); + transformer.applyAdviceToMethod( + named("getVirtualFieldValue") + .and(takesArgument(0, Object.class)) + .and(returns(Integer.class)), + this.getClass().getName() + "$VirtualFieldGetAdvice"); + transformer.applyAdviceToMethod( + named("localValue").and(takesArgument(0, int[].class)).and(returns(int[].class)), + this.getClass().getName() + "$LocalVariableAdvice"); + } + + @SuppressWarnings("unused") + public static class ModifyReturnValueAdvice { + + @Advice.OnMethodExit(suppress = Throwable.class) + public static void onExit(@Advice.Return(readOnly = false) int returnValue) { + returnValue = returnValue + 1; + } + } + + @SuppressWarnings("unused") + public static class ModifyArgumentsAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter(@Advice.Argument(value = 0, readOnly = false) int argument) { + argument = argument - 1; + } + } + + public static class VirtualFieldSetAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter( + @Advice.Argument(0) Object target, @Advice.Argument(1) Integer value) { + VirtualField field = VirtualField.find(Object.class, Integer.class); + field.set(target, value); + } + } + + @SuppressWarnings("unused") + public static class VirtualFieldGetAdvice { + @SuppressWarnings("UnusedVariable") + @Advice.OnMethodExit(suppress = Throwable.class) + public static void onExit( + @Advice.Argument(0) Object target, @Advice.Return(readOnly = false) Integer returnValue) { + VirtualField field = VirtualField.find(Object.class, Integer.class); + returnValue = field.get(target); + } + } + + @SuppressWarnings("unused") + public static class LocalVariableAdvice { + + @SuppressWarnings("UnusedVariable") + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter( + @Advice.Argument(0) int[] array, @Advice.Local("backup") int[] backupArray) { + backupArray = Arrays.copyOf(array, array.length); + } + + @SuppressWarnings("UnusedVariable") + @Advice.OnMethodExit(suppress = Throwable.class) + public static void onExit( + @Advice.Return(readOnly = false) int[] array, @Advice.Local("backup") int[] backupArray) { + array = Arrays.copyOf(backupArray, backupArray.length); + } + } +} diff --git a/smoke-tests/extensions/extension/src/main/java/io/opentelemetry/smoketest/extensions/inlined/SmokeInlinedInstrumentationModule.java b/smoke-tests/extensions/extension/src/main/java/io/opentelemetry/smoketest/extensions/inlined/SmokeInlinedInstrumentationModule.java new file mode 100644 index 000000000000..f38b1779d492 --- /dev/null +++ b/smoke-tests/extensions/extension/src/main/java/io/opentelemetry/smoketest/extensions/inlined/SmokeInlinedInstrumentationModule.java @@ -0,0 +1,25 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.smoketest.extensions.inlined; + +import com.google.auto.service.AutoService; +import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import java.util.Collections; +import java.util.List; + +@AutoService(InstrumentationModule.class) +public class SmokeInlinedInstrumentationModule extends InstrumentationModule { + + public SmokeInlinedInstrumentationModule() { + super("smoke-test-extension-inlined"); + } + + @Override + public List typeInstrumentations() { + return Collections.singletonList(new SmokeInlinedInstrumentation()); + } +} diff --git a/smoke-tests/extensions/testapp/build.gradle.kts b/smoke-tests/extensions/testapp/build.gradle.kts new file mode 100644 index 000000000000..8494723dea4e --- /dev/null +++ b/smoke-tests/extensions/testapp/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("otel.java-conventions") +} + +dependencies { +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks { + jar { + manifest { + attributes("Main-Class" to "io.opentelemetry.smoketest.extensions.app.AppMain") + } + } +} diff --git a/smoke-tests/extensions/testapp/src/main/java/io/opentelemetry/smoketest/extensions/app/AppMain.java b/smoke-tests/extensions/testapp/src/main/java/io/opentelemetry/smoketest/extensions/app/AppMain.java new file mode 100644 index 000000000000..507d758274c6 --- /dev/null +++ b/smoke-tests/extensions/testapp/src/main/java/io/opentelemetry/smoketest/extensions/app/AppMain.java @@ -0,0 +1,97 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.smoketest.extensions.app; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.io.PrintStream; +import java.util.Arrays; + +public class AppMain { + + private AppMain() {} + + public static void main(String[] args) { + testReturnValue(); + testMethodArguments(); + testVirtualFields(); + testLocalValue(); + } + + private static void msg(String msg) { + // avoid checkstyle to complain + PrintStream out = System.out; + out.println(msg); + } + + private static void testReturnValue() { + int returnValue = returnValue(42); + if (returnValue != 42) { + msg("return value has been modified"); + } else { + msg("return value not modified"); + } + } + + private static int returnValue(int value) { + // method return value should be modified by instrumentation + return value; + } + + private static void testMethodArguments() { + methodArguments(42, 42); + } + + private static void methodArguments(int argument, int originalArgument) { + // method first argument should be modified by instrumentation + if (argument != originalArgument) { + msg("argument has been modified"); + } else { + msg("argument not modified"); + } + } + + private static void testVirtualFields() { + Object target = new Object(); + setVirtualFieldValue(target, 42); + Integer fieldValue = getVirtualFieldValue(target); + if (fieldValue == null || fieldValue != 42) { + msg("virtual field not supported"); + } else { + msg("virtual field supported"); + } + } + + public static void setVirtualFieldValue(Object target, Integer value) { + // implementation should be provided by instrumentation + } + + public static Integer getVirtualFieldValue(Object target) { + // implementation should be provided by instrumentation + return null; + } + + private static void testLocalValue() { + int[] input = new int[] {1, 2, 3}; + int[] result = localValue(input); + if (result.length != 3) { + throw new IllegalStateException(); + } + // assumption on the instrumentation implementation to use a local value to preserve original + // array + boolean preserved = result[0] == 1 && result[1] == 2 && result[2] == 3; + if (!preserved) { + msg("local advice variable not supported"); + } else { + msg("local advice variable supported"); + } + } + + @CanIgnoreReturnValue + private static int[] localValue(int[] array) { + Arrays.fill(array, 0); + return array; + } +} diff --git a/smoke-tests/src/test/java/io/opentelemetry/smoketest/ExtensionsSmokeTest.java b/smoke-tests/src/test/java/io/opentelemetry/smoketest/ExtensionsSmokeTest.java new file mode 100644 index 000000000000..94746b0dd013 --- /dev/null +++ b/smoke-tests/src/test/java/io/opentelemetry/smoketest/ExtensionsSmokeTest.java @@ -0,0 +1,97 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.smoketest; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +class ExtensionsSmokeTest { + + private static final Logger logger = LoggerFactory.getLogger(ExtensionsSmokeTest.class); + + private static final String TARGET_AGENT_FILENAME = "/opentelemetry-javaagent.jar"; + private static final String agentPath = + System.getProperty("io.opentelemetry.smoketest.agent.shadowJar.path"); + + private static final String TARGET_EXTENSION_FILENAME = "/opentelemetry-extension.jar"; + private static final String extensionInlinePath = + System.getProperty("io.opentelemetry.smoketest.extension.path"); + + private static final String TARGET_APP_FILENAME = "/app.jar"; + private static final String appPath = + System.getProperty("io.opentelemetry.smoketest.extension.testapp.path"); + + private static final String IMAGE = "eclipse-temurin:21"; + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void inlinedExtension(boolean indy) throws InterruptedException { + + List cmd = new ArrayList<>(); + cmd.add("java"); + cmd.add("-javaagent:" + TARGET_AGENT_FILENAME); + + Map config = new HashMap<>(); + // disable export as we only instrument + config.put("otel.logs.exporter", "none"); + config.put("otel.metrics.exporter", "none"); + config.put("otel.traces.exporter", "none"); + // add extension + config.put("otel.javaagent.extensions", TARGET_EXTENSION_FILENAME); + // toggle indy on/off + config.put("otel.javaagent.experimental.indy", Boolean.toString(indy)); + // toggle debug if needed + config.put("otel.javaagent.debug", "false"); + config.forEach((k, v) -> cmd.add(String.format("-D%s=%s", k, v))); + + cmd.add("-jar"); + cmd.add(TARGET_APP_FILENAME); + + GenericContainer target = + new GenericContainer<>(DockerImageName.parse(IMAGE)) + .withStartupTimeout(Duration.ofMinutes(1)) + .withLogConsumer(new Slf4jLogConsumer(logger)) + .withCopyFileToContainer(MountableFile.forHostPath(agentPath), TARGET_AGENT_FILENAME) + .withCopyFileToContainer( + MountableFile.forHostPath(extensionInlinePath), TARGET_EXTENSION_FILENAME) + .withCopyFileToContainer(MountableFile.forHostPath(appPath), TARGET_APP_FILENAME) + .withCommand(String.join(" ", cmd)); + + logger.info("starting JVM with command: " + String.join(" ", cmd)); + target.start(); + while (target.isRunning()) { + Thread.sleep(100); + } + + List appOutput = + Arrays.asList(target.getLogs(OutputFrame.OutputType.STDOUT).split("\n")); + assertThat(appOutput) + .describedAs("return value instrumentation") + .contains("return value has been modified"); + assertThat(appOutput) + .describedAs("argument value instrumentation") + .contains("argument has been modified"); + assertThat(appOutput).describedAs("virtual field support").contains("virtual field supported"); + assertThat(appOutput) + .describedAs("local advice variable support") + .contains("local advice variable supported"); + } +}