diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 085a6dd5f5..e86f43961a 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -28,8 +28,9 @@ endif::[] * Add support for <> * Add `context.message.age.ms` field for JMS message receiving spans and transactions - {pull}970[#970] -* Instrument log4j Logger#error(String, Throwable) (#919) +* Instrument log4j Logger#error(String, Throwable) ({pull}919[#919]) Automatically captures exceptions when calling `logger.error("message", exception)` +* Add instrumentation for external process execution through `java.lang.Process` and Apache `commons-exec` - {pull}903[#903] [float] ===== Bug Fixes diff --git a/apm-agent-core/src/main/java/co/elastic/apm/agent/bci/ElasticApmAgent.java b/apm-agent-core/src/main/java/co/elastic/apm/agent/bci/ElasticApmAgent.java index d427194180..05beb2bace 100644 --- a/apm-agent-core/src/main/java/co/elastic/apm/agent/bci/ElasticApmAgent.java +++ b/apm-agent-core/src/main/java/co/elastic/apm/agent/bci/ElasticApmAgent.java @@ -74,6 +74,7 @@ import static net.bytebuddy.matcher.ElementMatchers.nameContains; import static net.bytebuddy.matcher.ElementMatchers.nameEndsWith; import static net.bytebuddy.matcher.ElementMatchers.nameStartsWith; +import static net.bytebuddy.matcher.ElementMatchers.named; import static net.bytebuddy.matcher.ElementMatchers.not; public class ElasticApmAgent { @@ -342,11 +343,16 @@ private static AgentBuilder getAgentBuilder(final ByteBuddy byteBuddy, final Cor : AgentBuilder.PoolStrategy.Default.FAST) .ignore(any(), isReflectionClassLoader()) .or(any(), classLoaderWithName("org.codehaus.groovy.runtime.callsite.CallSiteClassLoader")) + // ideally, those bootstrap classpath inclusions should be set at plugin level, see issue #952 .or(nameStartsWith("java.") .and( not( nameEndsWith("URLConnection") .or(nameStartsWith("java.util.concurrent.")) + .or(named("java.lang.ProcessBuilder")) + .or(named("java.lang.ProcessImpl")) + .or(named("java.lang.Process")) + .or(named("java.lang.UNIXProcess")) ) ) ) diff --git a/apm-agent-core/src/main/java/co/elastic/apm/agent/impl/ElasticApmTracer.java b/apm-agent-core/src/main/java/co/elastic/apm/agent/impl/ElasticApmTracer.java index f47b8709c8..884dc46a0c 100644 --- a/apm-agent-core/src/main/java/co/elastic/apm/agent/impl/ElasticApmTracer.java +++ b/apm-agent-core/src/main/java/co/elastic/apm/agent/impl/ElasticApmTracer.java @@ -391,7 +391,6 @@ public T getConfig(Class pluginClass) return configurationRegistry.getConfig(pluginClass); } - @SuppressWarnings("ReferenceEquality") public void endTransaction(Transaction transaction) { if (logger.isDebugEnabled()) { logger.debug("} endTransaction {}", transaction); @@ -408,7 +407,6 @@ public void endTransaction(Transaction transaction) { } } - @SuppressWarnings("ReferenceEquality") public void endSpan(Span span) { if (span.isSampled() && !span.isDiscard()) { long spanFramesMinDurationMs = stacktraceConfiguration.getSpanFramesMinDurationMs(); diff --git a/apm-agent-core/src/test/java/co/elastic/apm/agent/AbstractInstrumentationTest.java b/apm-agent-core/src/test/java/co/elastic/apm/agent/AbstractInstrumentationTest.java index 51e8c711be..09fc921b03 100644 --- a/apm-agent-core/src/test/java/co/elastic/apm/agent/AbstractInstrumentationTest.java +++ b/apm-agent-core/src/test/java/co/elastic/apm/agent/AbstractInstrumentationTest.java @@ -11,9 +11,9 @@ * 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 @@ -91,6 +91,9 @@ public final void resetReporter() { @AfterEach public final void cleanUp() { tracer.resetServiceNameOverrides(); - assertThat(tracer.getActive()).isNull(); + + assertThat(tracer.getActive()) + .describedAs("nothing should be left active at end of test, failure will likely indicate a span/transaction still active") + .isNull(); } } diff --git a/apm-agent-plugins/apm-process-plugin/README.md b/apm-agent-plugins/apm-process-plugin/README.md new file mode 100644 index 0000000000..34e3ee4105 --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/README.md @@ -0,0 +1,21 @@ +# Process plugin + +This plugin creates spans for external processes executed by the JVM, which use the `java.lang.Process` class. + +[Apache commons-exec](https://commons.apache.org/proper/commons-exec/) library support is included. + +## Limitations + +`java.lang.ProcessHandler` and `java.lang.Process.toHandle()` introduced in java 9 are not +instrumented. As a result, process execution using this API is not yet supported. + +## Implementation Notes + +Instrumentation of classes in `java.lang.*` that are loaded in the bootstrap classloader can't +be tested with unit tests due to the fact that agent is loaded in application/system classloader +for those tests. + +Thus, using integration tests is required to test the instrumentation end-to-end. +Also, in order to provide a good test of this feature without testing everything in integration tests, we +- delegate most of the advice code to helper classes +- test extensively those classes with regular unit tests diff --git a/apm-agent-plugins/apm-process-plugin/pom.xml b/apm-agent-plugins/apm-process-plugin/pom.xml new file mode 100644 index 0000000000..2df3e5cf80 --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/pom.xml @@ -0,0 +1,27 @@ + + + + apm-agent-plugins + co.elastic.apm + 1.12.1-SNAPSHOT + + 4.0.0 + + + ${project.basedir}/../.. + + + apm-process-plugin + ${project.groupId}:${project.artifactId} + + + + org.apache.commons + commons-exec + 1.3 + test + + + diff --git a/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/BaseProcessInstrumentation.java b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/BaseProcessInstrumentation.java new file mode 100644 index 0000000000..e899891987 --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/BaseProcessInstrumentation.java @@ -0,0 +1,48 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +package co.elastic.apm.agent.process; + +import co.elastic.apm.agent.bci.ElasticApmInstrumentation; +import net.bytebuddy.matcher.ElementMatcher; + +import java.util.Arrays; +import java.util.Collection; + +import static net.bytebuddy.matcher.ElementMatchers.isBootstrapClassLoader; + +public abstract class BaseProcessInstrumentation extends ElasticApmInstrumentation { + + @Override + public final ElementMatcher.Junction getClassLoaderMatcher() { + // java.lang.* is loaded from bootstrap classloader + return isBootstrapClassLoader(); + } + + @Override + public final Collection getInstrumentationGroupNames() { + return Arrays.asList("process", "incubating"); + } + +} diff --git a/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/CommonsExecAsyncInstrumentation.java b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/CommonsExecAsyncInstrumentation.java new file mode 100644 index 0000000000..5f5c219a8e --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/CommonsExecAsyncInstrumentation.java @@ -0,0 +1,101 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +package co.elastic.apm.agent.process; + +import co.elastic.apm.agent.bci.ElasticApmInstrumentation; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.NamedElement; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +import java.util.Arrays; +import java.util.Collection; + +import static net.bytebuddy.matcher.ElementMatchers.hasSuperClass; +import static net.bytebuddy.matcher.ElementMatchers.nameContains; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +/** + * Provides context propagation for apache commons-exec library that delegates to a background thread for + * asynchronous process execution. Synchronous execution is already covered with {@link Process} instrumentation. + *

+ * Instruments {@code org.apache.commons.exec.DefaultExecutor#createThread(Runnable, String)} and any direct subclass + * that overrides it. + */ +public class CommonsExecAsyncInstrumentation extends ElasticApmInstrumentation { + + private static final String DEFAULT_EXECUTOR_CLASS = "org.apache.commons.exec.DefaultExecutor"; + // only known subclass of default implementation + private static final String DAEMON_EXECUTOR_CLASS = "org.apache.commons.exec.DaemonExecutor"; + + @Override + public ElementMatcher getTypeMatcherPreFilter() { + // Most implementations are likely to have 'Executor' in their name, which will work most of the time + // while not perfect this allows to avoid the expensive 'hasSuperClass' in most cases + return nameContains("Executor"); + } + + @Override + public ElementMatcher getTypeMatcher() { + // instrument default implementation and direct subclasses + return named(DEFAULT_EXECUTOR_CLASS) + .or(named(DAEMON_EXECUTOR_CLASS)) + // this super class check is expensive + .or(hasSuperClass(named(DEFAULT_EXECUTOR_CLASS))); + } + + @Override + public ElementMatcher getMethodMatcher() { + return named("createThread") + .and(takesArgument(0, Runnable.class)); + } + + @Override + public Collection getInstrumentationGroupNames() { + // part of 'process' group, as usage is not relevant without it, relies on generic Process instrumentation + return Arrays.asList("apache-commons-exec", "process", "incubating"); + } + + @Override + public Class getAdviceClass() { + return CommonsExecAdvice.class; + } + + public static final class CommonsExecAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + private static void onEnter(@Advice.Argument(value = 0, readOnly = false) Runnable runnable) { + if (tracer == null || tracer.getActive() == null) { + return; + } + // context propagation is done by wrapping existing runnable argument + + //noinspection UnusedAssignment + runnable = tracer.getActive().withActive(runnable); + } + } +} diff --git a/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessExitInstrumentation.java b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessExitInstrumentation.java new file mode 100644 index 0000000000..ab3d6146a9 --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessExitInstrumentation.java @@ -0,0 +1,125 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +package co.elastic.apm.agent.process; + +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +import static net.bytebuddy.matcher.ElementMatchers.isPublic; +import static net.bytebuddy.matcher.ElementMatchers.named; + +public abstract class ProcessExitInstrumentation extends BaseProcessInstrumentation { + + // ProcessHandle added in java9, not supported yet, see issue #966 + + @Override + public final ElementMatcher getTypeMatcher() { + // on JDK 7-8 + // Windows : ProcessImpl extends Process + // Unix/Linux : UNIXProcess extends Process, ProcessImpl does not + // on JDK 9 and beyond + // All platforms: ProcessImpl extends Process + return named("java.lang.ProcessImpl") + .or(named("java.lang.UNIXProcess")); + } + + /** + * Instruments + *

    + *
  • {@code ProcessImpl#waitFor()}
  • + *
  • {@code ProcessImpl#waitFor(long, java.util.concurrent.TimeUnit)}
  • + *
  • {@code UNIXProcess#waitFor()}
  • + *
  • {@code UNIXProcess#waitFor(long, java.util.concurrent.TimeUnit)}
  • + *
+ */ + public static class WaitFor extends ProcessExitInstrumentation { + + @Override + public ElementMatcher getMethodMatcher() { + // will match both variants : with and without timeout + return named("waitFor"); + } + + @Override + public Class getAdviceClass() { + return WaitForAdvice.class; + } + + public static class WaitForAdvice { + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + private static void onExit(@Advice.This Process process) { + + if (tracer == null || tracer.getActive() == null) { + return; + } + + // waitFor should poll process termination if interrupted + ProcessHelper.endProcess(process, true); + } + } + } + + /** + * Instruments + *
    + *
  • {@code ProcessImpl#destroy}
  • + *
  • {@code ProcessImpl#destroyForcibly}
  • + *
  • {@code UNIXProcess#destroy}
  • + *
  • {@code UNIXProcess#destroyForcibly}
  • + *
+ */ + public static class Destroy extends ProcessExitInstrumentation { + + @Override + public ElementMatcher getMethodMatcher() { + return isPublic() + .and(named("destroy") + .or(named("destroyForcibly"))); + } + + @Override + public Class getAdviceClass() { + return DestroyAdvice.class; + } + + public static class DestroyAdvice { + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + private static void onExit(@Advice.This Process process) { + + if (tracer == null || tracer.getActive() == null) { + return; + } + + // because destroy will not terminate process immediately, we need to skip checking process termination + ProcessHelper.endProcess(process, false); + } + } + } + +} diff --git a/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessHelper.java b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessHelper.java new file mode 100644 index 0000000000..cd7d41df40 --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessHelper.java @@ -0,0 +1,119 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +package co.elastic.apm.agent.process; + +import co.elastic.apm.agent.bci.VisibleForAdvice; +import co.elastic.apm.agent.impl.transaction.Span; +import co.elastic.apm.agent.impl.transaction.TraceContextHolder; +import co.elastic.apm.agent.util.DataStructures; +import com.blogspot.mydailyjava.weaklockfree.WeakConcurrentMap; + +import javax.annotation.Nonnull; +import java.io.File; +import java.util.List; + +@VisibleForAdvice +public class ProcessHelper { + + private static final ProcessHelper INSTANCE = new ProcessHelper(new WeakConcurrentMap.WithInlinedExpunction()); + + private final WeakConcurrentMap inFlightSpans; + + ProcessHelper(WeakConcurrentMap inFlightSpans) { + this.inFlightSpans = inFlightSpans; + } + + @VisibleForAdvice + public static void startProcess(TraceContextHolder parentContext, Process process, List command) { + INSTANCE.doStartProcess(parentContext, process, command.get(0)); + } + + @VisibleForAdvice + public static void endProcess(@Nonnull Process process, boolean checkTerminatedProcess) { + INSTANCE.doEndProcess(process, checkTerminatedProcess); + } + + /** + * Starts process span + * + * @param parentContext parent context + * @param process started process + * @param processName process name + */ + void doStartProcess(@Nonnull TraceContextHolder parentContext, @Nonnull Process process, @Nonnull String processName) { + if (inFlightSpans.containsKey(process)) { + return; + } + + String binaryName = getBinaryName(processName); + + Span span = parentContext.createSpan() + .withType("process") + .withSubtype(binaryName) + .withAction("execute") + .withName(binaryName); + + // We don't require span to be activated as the background process is not really linked to current thread + // and there won't be any child span linked to process span + + inFlightSpans.put(process, span); + } + + private static String getBinaryName(String processName) { + int lastSeparator = processName.lastIndexOf(File.separatorChar); + return lastSeparator < 0 ? processName : processName.substring(lastSeparator + 1); + } + + /** + * Ends process span + * + * @param process process that is being terminated + * @param checkTerminatedProcess if {@code true}, will only terminate span if process is actually terminated, will + * unconditionally terminate process span otherwise + */ + void doEndProcess(Process process, boolean checkTerminatedProcess) { + + // borrowed from java 8 Process#isAlive() + // it has the same caveat as isAlive, which means that it will not detect process termination + // until the actual process has terminated, for example right after a call to Process#destroy(). + // in that case, ignoring the process actual status is relevant. + boolean terminated = !checkTerminatedProcess; + if (checkTerminatedProcess) { + try { + process.exitValue(); + terminated = true; + } catch (IllegalThreadStateException e) { + terminated = false; + } + } + + if (terminated) { + Span span = inFlightSpans.remove(process); + if (span != null) { + span.end(); + } + } + } +} diff --git a/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessStartInstrumentation.java b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessStartInstrumentation.java new file mode 100644 index 0000000000..d6012b53fb --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/ProcessStartInstrumentation.java @@ -0,0 +1,82 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +package co.elastic.apm.agent.process; + +import co.elastic.apm.agent.impl.transaction.TraceContextHolder; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +import javax.annotation.Nullable; + +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +/** + * Instruments {@link ProcessBuilder#start()} + */ +public class ProcessStartInstrumentation extends BaseProcessInstrumentation { + + @Override + public ElementMatcher getTypeMatcher() { + return named("java.lang.ProcessBuilder"); + } + + @Override + public ElementMatcher getMethodMatcher() { + return named("start") + .and(takesArguments(0)); + } + + @Override + public Class getAdviceClass() { + return ProcessBuilderStartAdvice.class; + } + + public static class ProcessBuilderStartAdvice { + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + public static void onExit(@Advice.This ProcessBuilder processBuilder, + @Advice.Return Process process, + @Advice.Thrown @Nullable Throwable t) { + + if (tracer == null) { + return; + } + TraceContextHolder parentSpan = tracer.getActive(); + if (parentSpan == null) { + return; + } + + if (t != null) { + // unable to start process, report exception as it's likely to be a bug + parentSpan.captureException(t); + } + + ProcessHelper.startProcess(parentSpan, process, processBuilder.command()); + } + } +} diff --git a/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/package-info.java b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/package-info.java new file mode 100644 index 0000000000..efe2409702 --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/main/java/co/elastic/apm/agent/process/package-info.java @@ -0,0 +1,28 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +@NonnullApi +package co.elastic.apm.agent.process; + +import co.elastic.apm.agent.annotation.NonnullApi; diff --git a/apm-agent-plugins/apm-process-plugin/src/main/resources/META-INF/services/co.elastic.apm.agent.bci.ElasticApmInstrumentation b/apm-agent-plugins/apm-process-plugin/src/main/resources/META-INF/services/co.elastic.apm.agent.bci.ElasticApmInstrumentation new file mode 100644 index 0000000000..a8ba368d6c --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/main/resources/META-INF/services/co.elastic.apm.agent.bci.ElasticApmInstrumentation @@ -0,0 +1,4 @@ +co.elastic.apm.agent.process.ProcessStartInstrumentation +co.elastic.apm.agent.process.ProcessExitInstrumentation$WaitFor +co.elastic.apm.agent.process.ProcessExitInstrumentation$Destroy +co.elastic.apm.agent.process.CommonsExecAsyncInstrumentation diff --git a/apm-agent-plugins/apm-process-plugin/src/test/java/co/elastic/apm/agent/process/CommonsExecAsyncInstrumentationTest.java b/apm-agent-plugins/apm-process-plugin/src/test/java/co/elastic/apm/agent/process/CommonsExecAsyncInstrumentationTest.java new file mode 100644 index 0000000000..8871f83235 --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/test/java/co/elastic/apm/agent/process/CommonsExecAsyncInstrumentationTest.java @@ -0,0 +1,163 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +package co.elastic.apm.agent.process; + +import co.elastic.apm.agent.AbstractInstrumentationTest; +import co.elastic.apm.agent.impl.transaction.TraceContext; +import co.elastic.apm.agent.impl.transaction.TraceContextHolder; +import co.elastic.apm.agent.impl.transaction.Transaction; +import org.apache.commons.exec.CommandLine; +import org.apache.commons.exec.DefaultExecuteResultHandler; +import org.apache.commons.exec.DefaultExecutor; +import org.apache.commons.exec.ExecuteException; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CommonsExecAsyncInstrumentationTest extends AbstractInstrumentationTest { + + @Test + void asyncProcessWithinTransaction() throws IOException, InterruptedException { + startTransaction(); + asyncProcessHasTransactionContext(true); + terminateTransaction(); + } + + @Test + void asyncProcessOutsideTransaction() throws IOException, InterruptedException { + asyncProcessHasTransactionContext(false); + } + + @Test + void customInstrumentationClassName() { + assertThat(MyExecutor.class.getSimpleName()) + .describedAs("'Executor' is required in subclass name for faster instrumentation non-matching") + .contains("Executor"); + } + + private static TraceContextHolder asyncProcessHasTransactionContext(boolean expectedInTransaction) throws IOException, InterruptedException { + AtomicReference> activeTransaction = new AtomicReference<>(); + + DefaultExecutor executor = new MyExecutor(activeTransaction); + + final AtomicBoolean processProperlyCompleted = new AtomicBoolean(false); + + DefaultExecuteResultHandler handler = new DefaultExecuteResultHandler() { + + // note: calling super is required otherwise process termination is not detected and waits forever + + @Override + public void onProcessComplete(int exitValue) { + super.onProcessComplete(exitValue); + processProperlyCompleted.set(exitValue == 0); + } + + @Override + public void onProcessFailed(ExecuteException e) { + super.onProcessFailed(e); + processProperlyCompleted.set(false); + } + }; + + executor.execute(new CommandLine(getJavaBinaryPath()).addArgument("-version"), handler); + handler.waitFor(); + + + assertThat(processProperlyCompleted.get()) + .describedAs("async process should have properly executed") + .isTrue(); + + if (expectedInTransaction) { + assertThat(activeTransaction.get()) + .describedAs("executor runnable not in the expected transaction context") + .isNotNull(); + } else { + assertThat(activeTransaction.get()) + .describedAs("executor runnable should not be in transaction context") + .isNull(); + } + + + return activeTransaction.get(); + } + + private static String getJavaBinaryPath() { + boolean isWindows = System.getProperty("os.name").startsWith("Windows"); + String executable = isWindows ? "java.exe" : "java"; + Path path = Paths.get(System.getProperty("java.home"), "bin", executable); + if (!Files.isExecutable(path)) { + throw new IllegalStateException("unable to find java path"); + } + return path.toAbsolutePath().toString(); + } + + private static void startTransaction() { + Transaction transaction = tracer.startTransaction(TraceContext.asRoot(), null, CommonsExecAsyncInstrumentationTest.class.getClassLoader()); + transaction.withType("request").activate(); + } + + private static void terminateTransaction() { + Transaction transaction = tracer.currentTransaction(); + assertThat(transaction).isNotNull(); + transaction.deactivate().end(); + + reporter.assertRecycledAfterDecrementingReferences(); + } + + /** + * Custom implementation for testing, requires to have 'Executor' in name + */ + private static class MyExecutor extends DefaultExecutor { + + private AtomicReference> activeTransaction; + + private MyExecutor(AtomicReference> activeTransaction) { + this.activeTransaction = activeTransaction; + } + + @Override + protected Thread createThread(final Runnable runnable, String name) { + Runnable wrapped = new Runnable() { + @Override + public void run() { + // we don't assert directly here as throwing an exception will wait forever + activeTransaction.set(tracer.getActive()); + + runnable.run(); + } + }; + return super.createThread(wrapped, name); + } + } + +} diff --git a/apm-agent-plugins/apm-process-plugin/src/test/java/co/elastic/apm/agent/process/ProcessHelperTest.java b/apm-agent-plugins/apm-process-plugin/src/test/java/co/elastic/apm/agent/process/ProcessHelperTest.java new file mode 100644 index 0000000000..992522d146 --- /dev/null +++ b/apm-agent-plugins/apm-process-plugin/src/test/java/co/elastic/apm/agent/process/ProcessHelperTest.java @@ -0,0 +1,187 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +package co.elastic.apm.agent.process; + +import co.elastic.apm.agent.AbstractInstrumentationTest; +import co.elastic.apm.agent.TransactionUtils; +import co.elastic.apm.agent.impl.transaction.Span; +import co.elastic.apm.agent.impl.transaction.Transaction; +import co.elastic.apm.agent.util.DataStructures; +import com.blogspot.mydailyjava.weaklockfree.WeakConcurrentMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.annotation.Nullable; + +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class ProcessHelperTest extends AbstractInstrumentationTest { + + // implementation note: + // + // Testing instrumentation of classes loaded by bootstrap classloader can't be done with regular unit tests + // as the agent is loaded in the application/system classloader when they are run. + // + // Hence, in order to maximize test coverage we thoroughly test helper implementation where most of the actual code + // of this instrumentation is. Also, integration test cover this feature for the general case with a packaged + // agent and thus they don't have such limitation + + @Nullable + private Transaction transaction = null; + + private WeakConcurrentMap storageMap; + private ProcessHelper helper; + + @BeforeEach + void before() { + transaction = new Transaction(tracer); + TransactionUtils.fillTransaction(transaction); + + storageMap = new WeakConcurrentMap.WithInlinedExpunction<>(); + helper = new ProcessHelper(storageMap); + } + + @Test + void checkSpanNaming() { + Process process = mock(Process.class); + + String binaryName = "hello"; + String programName = Paths.get("bin", binaryName).toAbsolutePath().toString(); + + helper.doStartProcess(transaction, process, programName); + + helper.doEndProcess(process, true); + + assertThat(reporter.getSpans()).hasSize(1); + Span span = reporter.getSpans().get(0); + + assertThat(span.getNameAsString()).isEqualTo(binaryName); + assertThat(span.getType()).isEqualTo("process"); + assertThat(span.getSubtype()).isEqualTo(binaryName); + assertThat(span.getAction()).isEqualTo("execute"); + } + + @Test + void startTwiceShouldIgnore() { + Process process = mock(Process.class); + + helper.doStartProcess(transaction, process, "hello"); + Span span = storageMap.get(process); + + helper.doStartProcess(transaction, process, "hello"); + assertThat(storageMap.get(process)) + .describedAs("initial span should not be overwritten") + .isSameAs(span); + } + + @Test + void endTwiceShouldIgnore() { + Process process = mock(Process.class); + + helper.doStartProcess(transaction, process, "hello"); + assertThat(storageMap).isNotEmpty(); + + helper.doEndProcess(process, true); + + // this second call should be ignored, thus exception not reported + helper.doEndProcess(process, true); + + assertThat(reporter.getSpans()).hasSize(1); + assertThat(reporter.getErrors()) + .describedAs("error should not be reported") + .isEmpty(); + } + + @Test + void executeMultipleProcessesInTransaction() { + Process process = mock(Process.class); + + helper.doStartProcess(transaction, process, "hello"); + helper.doEndProcess(process, true); + + helper.doStartProcess(transaction, process, "hello"); + helper.doEndProcess(process, true); + + assertThat(reporter.getSpans()).hasSize(2); + } + + @Test + void endUntrackedProcess() { + Process process = mock(Process.class); + helper.doEndProcess(process, true); + } + + @Test + void properlyTerminatedShouldNotLeak() { + Process process = mock(Process.class); + + helper.doStartProcess(transaction, process, "hello"); + assertThat(storageMap).isNotEmpty(); + + helper.doEndProcess(process, true); + assertThat(storageMap) + .describedAs("should remove process in map at end") + .isEmpty(); + } + + @Test + void waitForWithTimeoutDoesNotEndProcessSpan() { + Process process = mock(Process.class); + when(process.exitValue()) + // 1st call process not finished + .thenThrow(IllegalThreadStateException.class) + // 2cnd call process finished successfully + .thenReturn(0); + + helper.doStartProcess(transaction, process, "hello"); + + helper.doEndProcess(process, true); + assertThat(storageMap) + .describedAs("waitFor exit without exit status should not terminate span") + .isNotEmpty(); + + helper.doEndProcess(process, true); + assertThat(storageMap).isEmpty(); + } + + @Test + void destroyWithoutProcessTerminatedShouldEndSpan() { + Process process = mock(Process.class); + verifyNoMoreInteractions(process); // we should not even use any method of process + + helper.doStartProcess(transaction, process, "hello"); + + helper.doEndProcess(process, false); + assertThat(storageMap) + .describedAs("process span should be marked as terminated") + .isEmpty(); + } + +} diff --git a/apm-agent-plugins/pom.xml b/apm-agent-plugins/pom.xml index 8b49bb8120..fd2bf83c84 100644 --- a/apm-agent-plugins/pom.xml +++ b/apm-agent-plugins/pom.xml @@ -44,6 +44,7 @@ apm-jmx-plugin apm-mule4-plugin apm-mongoclient-plugin + apm-process-plugin diff --git a/docs/configuration.asciidoc b/docs/configuration.asciidoc index d6ca7e563e..b89fbc8175 100644 --- a/docs/configuration.asciidoc +++ b/docs/configuration.asciidoc @@ -404,7 +404,7 @@ you should add an additional entry to this list (make sure to also include the d ==== `disable_instrumentations` A list of instrumentations which should be disabled. -Valid options are `annotations`, `apache-httpclient`, `asynchttpclient`, `concurrent`, `elasticsearch-restclient`, `exception-handler`, `executor`, `hibernate-search`, `http-client`, `incubating`, `jax-rs`, `jax-ws`, `jdbc`, `jedis`, `jms`, `jsf`, `lettuce`, `log4j`, `logging`, `mongodb-client`, `mule`, `okhttp`, `opentracing`, `public-api`, `quartz`, `redis`, `render`, `scheduled`, `servlet-api`, `servlet-api-async`, `servlet-input-stream`, `slf4j`, `spring-mvc`, `spring-resttemplate`, `spring-service-name`, `spring-view-render`, `urlconnection`. +Valid options are `annotations`, `apache-commons-exec`, `apache-httpclient`, `asynchttpclient`, `concurrent`, `elasticsearch-restclient`, `exception-handler`, `executor`, `hibernate-search`, `http-client`, `incubating`, `jax-rs`, `jax-ws`, `jdbc`, `jedis`, `jms`, `jsf`, `lettuce`, `log4j`, `logging`, `mongodb-client`, `mule`, `okhttp`, `opentracing`, `process`, `public-api`, `quartz`, `redis`, `render`, `scheduled`, `servlet-api`, `servlet-api-async`, `servlet-input-stream`, `slf4j`, `spring-mvc`, `spring-resttemplate`, `spring-service-name`, `spring-view-render`, `urlconnection`. If you want to try out incubating features, set the value to an empty string. @@ -1721,7 +1721,7 @@ The default unit for this option is `ms` # sanitize_field_names=password,passwd,pwd,secret,*key,*token*,*session*,*credit*,*card*,authorization,set-cookie # A list of instrumentations which should be disabled. -# Valid options are `annotations`, `apache-httpclient`, `asynchttpclient`, `concurrent`, `elasticsearch-restclient`, `exception-handler`, `executor`, `hibernate-search`, `http-client`, `incubating`, `jax-rs`, `jax-ws`, `jdbc`, `jedis`, `jms`, `jsf`, `lettuce`, `log4j`, `logging`, `mongodb-client`, `mule`, `okhttp`, `opentracing`, `public-api`, `quartz`, `redis`, `render`, `scheduled`, `servlet-api`, `servlet-api-async`, `servlet-input-stream`, `slf4j`, `spring-mvc`, `spring-resttemplate`, `spring-service-name`, `spring-view-render`, `urlconnection`. +# Valid options are `annotations`, `apache-commons-exec`, `apache-httpclient`, `asynchttpclient`, `concurrent`, `elasticsearch-restclient`, `exception-handler`, `executor`, `hibernate-search`, `http-client`, `incubating`, `jax-rs`, `jax-ws`, `jdbc`, `jedis`, `jms`, `jsf`, `lettuce`, `log4j`, `logging`, `mongodb-client`, `mule`, `okhttp`, `opentracing`, `process`, `public-api`, `quartz`, `redis`, `render`, `scheduled`, `servlet-api`, `servlet-api-async`, `servlet-input-stream`, `slf4j`, `spring-mvc`, `spring-resttemplate`, `spring-service-name`, `spring-view-render`, `urlconnection`. # If you want to try out incubating features, # set the value to an empty string. # diff --git a/docs/supported-technologies.asciidoc b/docs/supported-technologies.asciidoc index a30eaabf48..07369330ed 100644 --- a/docs/supported-technologies.asciidoc +++ b/docs/supported-technologies.asciidoc @@ -13,6 +13,7 @@ This section lists all supported technologies. * <> * <> * <> +* <> * <> * <> * <> @@ -284,7 +285,7 @@ same trace. side, the agent reads the context from the Message property through `javax.jms.MessageConsumer#receive`, `javax.jms.MessageConsumer#receiveNoWait`, `javax.jms.JMSConsumer#receive`, `javax.jms.JMSConsumer#receiveNoWait` or `javax.jms.MessageListener#onMessage` and uses it for enabling distributed tracing. -|1.7.0 - Incubating (off by default). In order to enable, set the <> config option to an empty string +|1.7.0 - Incubating (off by default). Use <> to enable |=== @@ -336,6 +337,25 @@ NOTE: only classes from packages configured in <> |=== +[float] +[[supported-process-frameworks]] +=== Process frameworks + +|=== +|Framework |Supported versions | Description | Since + +|`java.lang.Process` +| +| Instruments `java.lang.Process` execution. Java 9 API using `ProcessHandler` is not supported yet. +| 1.13.0 - Incubating (off by default). Use <> to enable + +|Apache commons-exec +|1.3 +| Async process support through `org.apache.commons.exec.DefaultExecutor` and subclasses instrumentation. +| 1.13.0 - Incubating (off by default). Use <> to enable + +|=== + [float] [[supported-java-methods]] === Java method monitoring diff --git a/elastic-apm-agent/pom.xml b/elastic-apm-agent/pom.xml index 842fa05176..5c2499703a 100644 --- a/elastic-apm-agent/pom.xml +++ b/elastic-apm-agent/pom.xml @@ -179,6 +179,11 @@ apm-quartz-job-plugin ${project.version} + + ${project.groupId} + apm-process-plugin + ${project.version} + diff --git a/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/AbstractServletContainerIntegrationTest.java b/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/AbstractServletContainerIntegrationTest.java index 27393abcf2..62555abacc 100644 --- a/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/AbstractServletContainerIntegrationTest.java +++ b/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/AbstractServletContainerIntegrationTest.java @@ -140,6 +140,7 @@ protected AbstractServletContainerIntegrationTest(GenericContainer servletCon .withEnv("ELASTIC_APM_CAPTURE_JMX_METRICS", "object_name[java.lang:type=Memory] attribute[HeapMemoryUsage:metric_name=test_heap_metric]") .withEnv("ELASTIC_APM_CAPTURE_BODY", "all") .withEnv("ELASTIC_APM_TRACE_METHODS", "public @@javax.enterprise.context.NormalScope co.elastic.*") + .withEnv("ELASTIC_APM_DISABLED_INSTRUMENTATIONS", "") // enable all instrumentations for integration tests .withLogConsumer(new StandardOutLogConsumer().withPrefix(containerName)) .withExposedPorts(webPort) .withFileSystemBind(pathToJavaagent, "/elastic-apm-agent.jar") @@ -340,7 +341,9 @@ public List assertSpansTransactionId(Supplier> supplier do { reportedSpans = supplier.get(); } while (reportedSpans.size() == 0 && System.currentTimeMillis() - start < timeout); - assertThat(reportedSpans.size()).isGreaterThanOrEqualTo(1); + assertThat(reportedSpans) + .describedAs("at least one span is expected") + .isNotEmpty(); for (JsonNode span : reportedSpans) { assertThat(span.get("transaction_id").textValue()).isEqualTo(transactionId); } @@ -455,11 +458,13 @@ private void validateEventMetadata(String bodyAsString) { } private void validateServiceName(JsonNode event) { - if (currentTestApp.getExpectedServiceName() != null && event != null) { - assertThat(event.get("context").get("service")) - .withFailMessage("No service name set. Expected '%s'. Event was %s", currentTestApp.getExpectedServiceName(), event) + String expectedServiceName = currentTestApp.getExpectedServiceName(); + if (expectedServiceName != null && event != null) { + JsonNode contextService = event.get("context").get("service"); + assertThat(contextService) + .withFailMessage("No service name set. Expected '%s'. Event was %s", expectedServiceName, event) .isNotNull(); - assertThat(event.get("context").get("service").get("name").textValue()).isEqualTo(currentTestApp.getExpectedServiceName()); + assertThat(contextService.get("name").textValue()).isEqualTo(expectedServiceName); } } diff --git a/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/JettyIT.java b/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/JettyIT.java index 454e8d537a..eab2625fad 100644 --- a/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/JettyIT.java +++ b/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/JettyIT.java @@ -11,9 +11,9 @@ * 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 diff --git a/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/tests/ServletApiTestApp.java b/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/tests/ServletApiTestApp.java index 7dae4256ca..0926dd4bc9 100644 --- a/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/tests/ServletApiTestApp.java +++ b/integration-tests/application-server-integration-tests/src/test/java/co/elastic/apm/servlet/tests/ServletApiTestApp.java @@ -53,6 +53,7 @@ public void test(AbstractServletContainerIntegrationTest test) throws Exception testTransactionErrorReporting(test); testSpanErrorReporting(test); testExecutorService(test); + testExecuteCommandServlet(test); testHttpUrlConnection(test); testCaptureBody(test); testJmxMetrics(test); @@ -83,6 +84,26 @@ private void testExecutorService(AbstractServletContainerIntegrationTest test) t assertThat(spans).hasSize(1); } + private void testExecuteCommandServlet(AbstractServletContainerIntegrationTest test) throws Exception { + testExecuteCommand(test, "?variant=WAIT_FOR"); + testExecuteCommand(test, "?variant=DESTROY"); + } + + private void testExecuteCommand(AbstractServletContainerIntegrationTest test, String queryPath) throws IOException, InterruptedException { + test.clearMockServerLog(); + final String pathToTest = "/simple-webapp/execute-cmd-servlet"; + test.executeAndValidateRequest(pathToTest + queryPath, null, 200, null); + String transactionId = test.assertTransactionReported(pathToTest, 200).get("id").textValue(); + List spans = test.assertSpansTransactionId(test::getReportedSpans, transactionId); + assertThat(spans).hasSize(1); + + for (JsonNode span : spans) { + assertThat(span.get("parent_id").textValue()).isEqualTo(transactionId); + assertThat(span.get("name").asText()).isEqualTo("java"); + assertThat(span.get("type").asText()).isEqualTo("process.java.execute"); + } + } + private void testHttpUrlConnection(AbstractServletContainerIntegrationTest test) throws IOException, InterruptedException { test.clearMockServerLog(); final String pathToTest = "/simple-webapp/http-url-connection"; diff --git a/integration-tests/simple-webapp/src/main/java/co/elastic/webapp/ExecuteCmdServlet.java b/integration-tests/simple-webapp/src/main/java/co/elastic/webapp/ExecuteCmdServlet.java new file mode 100644 index 0000000000..f07c08782d --- /dev/null +++ b/integration-tests/simple-webapp/src/main/java/co/elastic/webapp/ExecuteCmdServlet.java @@ -0,0 +1,95 @@ +/*- + * #%L + * Elastic APM Java agent + * %% + * Copyright (C) 2018 - 2019 Elastic and contributors + * %% + * 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. + * #L% + */ +package co.elastic.webapp; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; + +public class ExecuteCmdServlet extends HttpServlet { + + private enum Variant { + WAIT_FOR, + DESTROY + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException { + String[] cmd = new String[]{getJavaBinaryPath(), "-version"}; + + String variant = req.getParameter("variant"); + Variant v = variant != null ? Variant.valueOf(variant) : Variant.WAIT_FOR; + + int returnValue; + + try { + Process process = Runtime.getRuntime().exec(cmd); + switch (v) { + case DESTROY: + process.destroy(); + returnValue = -1; + break; + case WAIT_FOR: + returnValue = process.waitFor(); + break; + default: + throw new IllegalStateException(); + } + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + + try { + PrintWriter writer = resp.getWriter(); + writeMsg(writer, "using variant = %s", v); + writeMsg(writer, "command = %s", Arrays.toString(cmd)); + writeMsg(writer, "return code = %d", returnValue); + } catch (Exception e) { + throw new ServletException(e); + } + } + + private static void writeMsg(PrintWriter writer, String msg, Object... msgArgs) { + writer.println(String.format(msg, msgArgs)); + } + + private static String getJavaBinaryPath() { + boolean isWindows = System.getProperty("os.name").startsWith("Windows"); + String executable = isWindows ? "java.exe" : "java"; + Path path = Paths.get(System.getProperty("java.home"), "bin", executable); + if (!Files.isExecutable(path)) { + throw new IllegalStateException("unable to find java path"); + } + return path.toAbsolutePath().toString(); + } + +} diff --git a/integration-tests/simple-webapp/src/main/webapp/WEB-INF/web.xml b/integration-tests/simple-webapp/src/main/webapp/WEB-INF/web.xml index 446ab6850a..dc1b98c0c8 100644 --- a/integration-tests/simple-webapp/src/main/webapp/WEB-INF/web.xml +++ b/integration-tests/simple-webapp/src/main/webapp/WEB-INF/web.xml @@ -70,4 +70,14 @@ /echo + + ExecuteCmdServlet + co.elastic.webapp.ExecuteCmdServlet + 1 + true + + + ExecuteCmdServlet + /execute-cmd-servlet +