diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 987c301ec9..712727963a 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -31,6 +31,10 @@ Use subheadings with the "=====" level for adding notes for unreleased changes: === Unreleased +[float] +===== Features +* Virtual thread support - {pull}3244[#3244] + [float] ===== Bug fixes * Fix JVM memory usage capture - {pull}3279[#3279] diff --git a/apm-agent-plugin-sdk/src/main/java/co/elastic/apm/agent/sdk/internal/ThreadUtil.java b/apm-agent-plugin-sdk/src/main/java/co/elastic/apm/agent/sdk/internal/ThreadUtil.java new file mode 100644 index 0000000000..59c2c6cd07 --- /dev/null +++ b/apm-agent-plugin-sdk/src/main/java/co/elastic/apm/agent/sdk/internal/ThreadUtil.java @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package co.elastic.apm.agent.sdk.internal; + +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; +import net.bytebuddy.implementation.MethodCall; +import net.bytebuddy.matcher.ElementMatchers; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +public class ThreadUtil { + + interface VirtualChecker { + boolean isVirtual(Thread thread); + } + + private static final VirtualChecker VIRTUAL_CHECKER = generateVirtualChecker(); + + + public static boolean isVirtual(Thread thread) { + return VIRTUAL_CHECKER.isVirtual(thread); + } + + /** + * Generates a VirtualChecker based on the current JVM. + * If the JVM does not support virtual threads, a VirtualChecker which always returns false is returned. + *

+ * Otherwise we runtime generate an implementation which invokes Thread.isVirtual(). + * We use runtime proxy generation because Thread.isVirtual() has been added in Java 19 as preview and Java 21 as non preview. + * Therefore we would require a compilation with Java 19 (non-LTS), because Java 20+ does not allow targeting Java 7. + *

+ * Alternatively we could simply invoke Thread.isVirtual via reflection. + * However, because this check can be used very frequently we want to avoid the penalty / missing inline capability of reflection. + * + * @return the implementation for {@link VirtualChecker}. + */ + private static VirtualChecker generateVirtualChecker() { + Method isVirtual = null; + try { + isVirtual = Thread.class.getMethod("isVirtual"); + isVirtual.invoke(Thread.currentThread()); //invoke to ensure it does not throw exceptions for preview versions + Class impl = new ByteBuddy() + .subclass(VirtualChecker.class) + .method(ElementMatchers.named("isVirtual")) + .intercept(MethodCall.invoke(isVirtual).onArgument(0)) + .make() + .load(VirtualChecker.class.getClassLoader(), ClassLoadingStrategy.Default.INJECTION) + .getLoaded(); + return impl.getConstructor().newInstance(); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + return new VirtualChecker() { + @Override + public boolean isVirtual(Thread thread) { + return false; //virtual threads are not supported, therefore no thread is virtual + } + }; + } catch (InstantiationException e) { + throw new RuntimeException(e); + } + } +} diff --git a/apm-agent-plugin-sdk/src/test/java/co/elastic/apm/agent/sdk/internal/ThreadUtilTest.java b/apm-agent-plugin-sdk/src/test/java/co/elastic/apm/agent/sdk/internal/ThreadUtilTest.java new file mode 100644 index 0000000000..d14d037b10 --- /dev/null +++ b/apm-agent-plugin-sdk/src/test/java/co/elastic/apm/agent/sdk/internal/ThreadUtilTest.java @@ -0,0 +1,43 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package co.elastic.apm.agent.sdk.internal; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class ThreadUtilTest { + + @Test + public void checkPlatformThreadVirtual() { + Thread t1 = new Thread(); + assertThat(ThreadUtil.isVirtual(t1)).isFalse(); + } + + @Test + @DisabledForJreRange(max = JRE.JAVA_20) + public void checkVirtualThreadVirtual() throws Exception { + Runnable task = () -> { + }; + Thread thread = (Thread) Thread.class.getMethod("startVirtualThread", Runnable.class).invoke(null, task); + assertThat(ThreadUtil.isVirtual(thread)).isTrue(); + } +} diff --git a/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/ProfilingActivationListener.java b/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/ProfilingActivationListener.java index 277759536f..2e299f63d5 100644 --- a/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/ProfilingActivationListener.java +++ b/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/ProfilingActivationListener.java @@ -21,6 +21,7 @@ import co.elastic.apm.agent.impl.ActivationListener; import co.elastic.apm.agent.impl.ElasticApmTracer; import co.elastic.apm.agent.impl.transaction.AbstractSpan; +import co.elastic.apm.agent.sdk.internal.ThreadUtil; import java.util.Objects; @@ -40,7 +41,7 @@ public ProfilingActivationListener(ElasticApmTracer tracer) { @Override public void beforeActivate(AbstractSpan context) { - if (context.isSampled()) { + if (context.isSampled() && !ThreadUtil.isVirtual(Thread.currentThread())) { AbstractSpan active = tracer.getActive(); profiler.onActivation(context.getTraceContext(), active != null ? active.getTraceContext() : null); } @@ -48,7 +49,7 @@ public void beforeActivate(AbstractSpan context) { @Override public void afterDeactivate(AbstractSpan deactivatedContext) { - if (deactivatedContext.isSampled()) { + if (deactivatedContext.isSampled() && !ThreadUtil.isVirtual(Thread.currentThread())) { AbstractSpan active = tracer.getActive(); profiler.onDeactivation(deactivatedContext.getTraceContext(), active != null ? active.getTraceContext() : null); } diff --git a/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/ProfilingConfiguration.java b/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/ProfilingConfiguration.java index 8bad530dde..a58b4e67ea 100644 --- a/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/ProfilingConfiguration.java +++ b/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/ProfilingConfiguration.java @@ -18,10 +18,10 @@ */ package co.elastic.apm.agent.profiler; +import co.elastic.apm.agent.common.util.WildcardMatcher; import co.elastic.apm.agent.tracer.configuration.ListValueConverter; import co.elastic.apm.agent.tracer.configuration.TimeDuration; import co.elastic.apm.agent.tracer.configuration.TimeDurationValueConverter; -import co.elastic.apm.agent.common.util.WildcardMatcher; import co.elastic.apm.agent.tracer.configuration.WildcardMatcherValueConverter; import org.stagemonitor.configuration.ConfigurationOption; import org.stagemonitor.configuration.ConfigurationOptionProvider; @@ -49,6 +49,8 @@ public class ProfilingConfiguration extends ConfigurationOptionProvider { "The inferred spans are created after a profiling session has ended.\n" + "This means there is a delay between the regular and the inferred spans being visible in the UI.\n" + "\n" + + "Only platform threads are supported. Virtual threads are not supported and will not be profiled.\n" + + "\n" + "NOTE: This feature is not available on Windows and on OpenJ9") .dynamic(true) .tags("added[1.15.0]", "experimental") diff --git a/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/SamplingProfiler.java b/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/SamplingProfiler.java index 8b1f8daafb..1a33248b5a 100644 --- a/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/SamplingProfiler.java +++ b/apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/SamplingProfiler.java @@ -18,18 +18,20 @@ */ package co.elastic.apm.agent.profiler; -import co.elastic.apm.agent.sdk.internal.util.ExecutorUtils; -import co.elastic.apm.agent.tracer.configuration.CoreConfiguration; -import co.elastic.apm.agent.tracer.configuration.TimeDuration; +import co.elastic.apm.agent.common.util.WildcardMatcher; import co.elastic.apm.agent.context.AbstractLifecycleListener; import co.elastic.apm.agent.impl.ElasticApmTracer; import co.elastic.apm.agent.impl.transaction.Span; import co.elastic.apm.agent.impl.transaction.StackFrame; import co.elastic.apm.agent.impl.transaction.TraceContext; -import co.elastic.apm.agent.common.util.WildcardMatcher; import co.elastic.apm.agent.profiler.asyncprofiler.AsyncProfiler; import co.elastic.apm.agent.profiler.asyncprofiler.JfrParser; import co.elastic.apm.agent.profiler.collections.Long2ObjectHashMap; +import co.elastic.apm.agent.sdk.internal.util.ExecutorUtils; +import co.elastic.apm.agent.sdk.logging.Logger; +import co.elastic.apm.agent.sdk.logging.LoggerFactory; +import co.elastic.apm.agent.tracer.configuration.CoreConfiguration; +import co.elastic.apm.agent.tracer.configuration.TimeDuration; import co.elastic.apm.agent.tracer.pooling.Allocator; import co.elastic.apm.agent.tracer.pooling.ObjectPool; import com.lmax.disruptor.EventFactory; @@ -39,8 +41,6 @@ import com.lmax.disruptor.Sequence; import com.lmax.disruptor.SequenceBarrier; import com.lmax.disruptor.WaitStrategy; -import co.elastic.apm.agent.sdk.logging.Logger; -import co.elastic.apm.agent.sdk.logging.LoggerFactory; import javax.annotation.Nullable; import java.io.File; @@ -226,6 +226,17 @@ public CallTree.Root createInstance() { this.activationEventsFile = activationEventsFile; } + /** + * For testing only! + * This method must only be called in tests and some period after activation / deactivation events, as otherwise it is racy. + * + * @param thread the Thread to check. + * @return true, if profiling is active for the given thread. + */ + boolean isProfilingActiveOnThread(Thread thread) { + return profiledThreads.containsKey(thread.getId()); + } + private synchronized void createFilesIfRequired() throws IOException { if (jfrFile == null || !jfrFile.exists()) { jfrFile = File.createTempFile("apm-traces-", ".jfr"); diff --git a/apm-agent-plugins/apm-profiling-plugin/src/test/java/co/elastic/apm/agent/profiler/SamplingProfilerTest.java b/apm-agent-plugins/apm-profiling-plugin/src/test/java/co/elastic/apm/agent/profiler/SamplingProfilerTest.java index 6204d4d03e..6e5816777e 100644 --- a/apm-agent-plugins/apm-profiling-plugin/src/test/java/co/elastic/apm/agent/profiler/SamplingProfilerTest.java +++ b/apm-agent-plugins/apm-profiling-plugin/src/test/java/co/elastic/apm/agent/profiler/SamplingProfilerTest.java @@ -20,29 +20,33 @@ import co.elastic.apm.agent.MockReporter; import co.elastic.apm.agent.MockTracer; +import co.elastic.apm.agent.common.util.WildcardMatcher; import co.elastic.apm.agent.configuration.SpyConfiguration; -import co.elastic.apm.agent.tracer.configuration.TimeDuration; import co.elastic.apm.agent.impl.ElasticApmTracer; import co.elastic.apm.agent.impl.transaction.Span; import co.elastic.apm.agent.impl.transaction.Transaction; -import co.elastic.apm.agent.common.util.WildcardMatcher; import co.elastic.apm.agent.testutils.DisabledOnAppleSilicon; import co.elastic.apm.agent.tracer.Scope; +import co.elastic.apm.agent.tracer.configuration.TimeDuration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledForJreRange; import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.condition.OS; import org.stagemonitor.configuration.ConfigurationRegistry; import javax.annotation.Nullable; import java.io.IOException; +import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -164,6 +168,7 @@ void testProfileTransaction() throws Exception { // makes sure that the rest will be captured by another profiling session // this tests that restoring which threads to profile works Thread.sleep(600); + assertThat(profiler.isProfilingActiveOnThread(Thread.currentThread())).isTrue(); aInferred(transaction); } finally { transaction.end(); @@ -195,6 +200,38 @@ void testProfileTransaction() throws Exception { assertThat(inferredSpanD.get().isChildOf(inferredSpanC.get())).isTrue(); } + @Test + @DisabledForJreRange(max = JRE.JAVA_20) + void testVirtualThreadsExcluded() throws Exception { + setupProfiler(true); + awaitProfilerStarted(profiler); + + AtomicReference profilingActive = new AtomicReference<>(); + Runnable task = () -> { + Transaction transaction = tracer.startRootTransaction(null).withName("transaction"); + try (Scope scope = transaction.activateInScope()) { + // makes sure that the rest will be captured by another profiling session + // this tests that restoring which threads to profile works + try { + Thread.sleep(600); + } catch (Exception e) { + throw new RuntimeException(e); + } + profilingActive.set(profiler.isProfilingActiveOnThread(Thread.currentThread())); + } finally { + transaction.end(); + } + }; + + Method startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + Thread virtual = (Thread) startVirtualThread.invoke(null, task); + virtual.join(); + + assertThat(profilingActive.get()).isFalse(); + + } + + @Test void testPostProcessingDisabled() throws Exception { setupProfiler(true); diff --git a/docs/configuration.asciidoc b/docs/configuration.asciidoc index 7cbee21ddd..9a2ab32449 100644 --- a/docs/configuration.asciidoc +++ b/docs/configuration.asciidoc @@ -2608,6 +2608,8 @@ The <> @@ -4586,6 +4588,8 @@ Example: `5ms`. # The inferred spans are created after a profiling session has ended. # This means there is a delay between the regular and the inferred spans being visible in the UI. # +# Only platform threads are supported. Virtual threads are not supported and will not be profiled. +# # NOTE: This feature is not available on Windows and on OpenJ9 # # This setting can be changed at runtime