From b32b1c10b7709b38c7cc960b12f98173ff6472d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20=C3=81lvarez=20=C3=81lvarez?= Date: Thu, 15 Sep 2022 12:06:19 +0200 Subject: [PATCH] Add call-site-instrumentation-plugin to build advice classes --- buildSrc/build.gradle.kts | 5 + .../build.gradle.kts | 87 +++ .../trace/plugin/csi/AdviceGenerator.java | 67 ++ .../plugin/csi/AdvicePointcutParser.java | 32 + .../trace/plugin/csi/CallSiteReporter.java | 76 +++ .../datadog/trace/plugin/csi/HasErrors.java | 151 +++++ .../trace/plugin/csi/PluginApplication.java | 136 ++++ .../plugin/csi/SpecificationBuilder.java | 19 + .../trace/plugin/csi/TypeResolver.java | 32 + .../datadog/trace/plugin/csi/Validatable.java | 8 + .../trace/plugin/csi/ValidationContext.java | 27 + .../csi/impl/AsmSpecificationBuilder.java | 282 ++++++++ .../plugin/csi/impl/CallSiteFactory.java | 38 ++ .../csi/impl/CallSiteSpecification.java | 624 ++++++++++++++++++ .../csi/impl/FreemarkerAdviceGenerator.java | 275 ++++++++ .../csi/impl/RegexpAdvicePointcutParser.java | 142 ++++ .../plugin/csi/impl/TypeResolverPool.java | 116 ++++ .../plugin/csi/util/CallSiteConstants.java | 36 + .../trace/plugin/csi/util/CallSiteUtils.java | 70 ++ .../trace/plugin/csi/util/ErrorCode.java | 403 +++++++++++ .../trace/plugin/csi/util/MethodType.java | 83 +++ .../src/main/resources/csi/advice.ftl | 63 ++ .../src/main/resources/csi/console.ftl | 15 + .../csi/impl/AdviceSpecificationTest.groovy | 542 +++++++++++++++ .../impl/AsmSpecificationBuilderTest.groovy | 452 +++++++++++++ .../plugin/csi/impl/BaseCsiPluginTest.groovy | 194 ++++++ .../csi/impl/CallSiteSpecificationTest.groovy | 45 ++ .../impl/FreemarkerAdviceGeneratorTest.groovy | 374 +++++++++++ .../RegexpAdvicePointcutParserTest.groovy | 136 ++++ .../csi/impl/TypeResolverPoolTest.groovy | 109 +++ buildSrc/settings.gradle | 1 + .../CallSiteInstrumentationPlugin.groovy | 205 ++++++ .../CallSiteInstrumentationPluginTest.groovy | 137 ++++ .../trace/agent/tooling/csi/CallSite.java | 97 +++ 34 files changed, 5079 insertions(+) create mode 100644 buildSrc/call-site-instrumentation-plugin/build.gradle.kts create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdviceGenerator.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdvicePointcutParser.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/CallSiteReporter.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/HasErrors.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/PluginApplication.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/SpecificationBuilder.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/TypeResolver.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Validatable.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/ValidationContext.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilder.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteFactory.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteSpecification.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/FreemarkerAdviceGenerator.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParser.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/TypeResolverPool.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteConstants.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteUtils.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/ErrorCode.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/MethodType.java create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/resources/csi/advice.ftl create mode 100644 buildSrc/call-site-instrumentation-plugin/src/main/resources/csi/console.ftl create mode 100644 buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/AdviceSpecificationTest.groovy create mode 100644 buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/AsmSpecificationBuilderTest.groovy create mode 100644 buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/BaseCsiPluginTest.groovy create mode 100644 buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/CallSiteSpecificationTest.groovy create mode 100644 buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest.groovy create mode 100644 buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParserTest.groovy create mode 100644 buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/TypeResolverPoolTest.groovy create mode 100644 buildSrc/settings.gradle create mode 100644 buildSrc/src/main/groovy/CallSiteInstrumentationPlugin.groovy create mode 100644 buildSrc/src/test/groovy/CallSiteInstrumentationPluginTest.groovy create mode 100644 dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/csi/CallSite.java diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 839cf5922e8..b97ca9114e7 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -14,6 +14,10 @@ gradlePlugin { id = "muzzle" implementationClass = "MuzzlePlugin" } + create("call-site-instrumentation-plugin") { + id = "call-site-instrumentation" + implementationClass = "CallSiteInstrumentationPlugin" + } } } @@ -43,4 +47,5 @@ dependencies { tasks.test { useJUnitPlatform() + dependsOn(":call-site-instrumentation-plugin:build") } diff --git a/buildSrc/call-site-instrumentation-plugin/build.gradle.kts b/buildSrc/call-site-instrumentation-plugin/build.gradle.kts new file mode 100644 index 00000000000..f9796450bbf --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/build.gradle.kts @@ -0,0 +1,87 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + java + groovy + id("com.diffplug.spotless") version "5.11.0" + id("com.github.johnrengelman.shadow") version "7.1.2" +} + +spotless { + java { + toggleOffOn() + // set explicit target to workaround https://github.com/diffplug/spotless/issues/1163 + target("src/**/*.java") + // ignore embedded test projects + targetExclude("src/test/resources/**") + googleJavaFormat() + } +} + +repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() +} + +dependencies { + compileOnly("com.google.code.findbugs", "jsr305", "3.0.2") + + implementation("org.freemarker", "freemarker", "2.3.30") + implementation("org.ow2.asm", "asm", "9.0") + implementation("org.ow2.asm", "asm-tree", "9.0") + + testImplementation("org.spockframework", "spock-core", "2.0-groovy-3.0") + testImplementation("org.codehaus.groovy", "groovy-all", "3.0.10") + testImplementation("com.github.javaparser", "javaparser-symbol-solver-core", "3.24.4") + testImplementation("javax.servlet", "javax.servlet-api", "3.0.1") +} + +sourceSets { + test { + java { + srcDirs("src/test/java", "$buildDir/generated/sources/csi") + } + } +} + +val copyCallSiteSources = tasks.register("copyCallSiteSources") { + val csiPackage = "datadog/trace/agent/tooling/csi" + val source = layout.projectDirectory.file("../../dd-java-agent/agent-tooling/src/main/java/$csiPackage") + val target = layout.buildDirectory.dir("generated/sources/csi/$csiPackage") + doFirst { + val folder = target.get().asFile + if (folder.exists() && !folder.deleteRecursively()) { + throw GradleException("Cannot delete files in $folder") + } + } + from(source) + into(target) + group = "build" +} + +tasks { + withType() { + dependsOn(copyCallSiteSources) + } +} + +tasks { + named("shadowJar") { + archiveBaseName.set("call-site-instrumentation-plugin") + archiveClassifier.set("") + archiveVersion.set("") + mergeServiceFiles() + manifest { + attributes(mapOf("Main-Class" to "datadog.trace.plugin.csi.PluginApplication")) + } + } +} + +tasks.build { + dependsOn(tasks.shadowJar) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdviceGenerator.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdviceGenerator.java new file mode 100644 index 00000000000..c7867bf0937 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdviceGenerator.java @@ -0,0 +1,67 @@ +package datadog.trace.plugin.csi; + +import datadog.trace.plugin.csi.ValidationContext.BaseValidationContext; +import datadog.trace.plugin.csi.impl.CallSiteSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AdviceSpecification; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import javax.annotation.Nonnull; + +/** + * Implementors of this interface will build the final Java source code files implementing the + * {@link datadog.trace.agent.tooling.csi.CallSiteAdvice} interface + */ +public interface AdviceGenerator { + + @Nonnull + CallSiteResult generate(@Nonnull CallSiteSpecification callSite); + + final class CallSiteResult extends BaseValidationContext { + + private final CallSiteSpecification specification; + private final List advices = new ArrayList<>(); + + public CallSiteResult(@Nonnull final CallSiteSpecification specification) { + this.specification = specification; + } + + @Override + public boolean isSuccess() { + return super.isSuccess() && getAdvices().allMatch(AdviceResult::isSuccess); + } + + public Stream getAdvices() { + return advices.stream(); + } + + public void addAdvice(final AdviceResult advice) { + this.advices.add(advice); + } + + public CallSiteSpecification getSpecification() { + return specification; + } + } + + final class AdviceResult extends BaseValidationContext { + + private final AdviceSpecification specification; + private final File file; + + public AdviceResult( + @Nonnull final AdviceSpecification specification, @Nonnull final File file) { + this.specification = specification; + this.file = file; + } + + public AdviceSpecification getSpecification() { + return specification; + } + + public File getFile() { + return file; + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdvicePointcutParser.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdvicePointcutParser.java new file mode 100644 index 00000000000..3636f05b6e3 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdvicePointcutParser.java @@ -0,0 +1,32 @@ +package datadog.trace.plugin.csi; + +import datadog.trace.plugin.csi.HasErrors.HasErrorsException; +import datadog.trace.plugin.csi.util.MethodType; +import java.util.Collection; +import javax.annotation.Nonnull; + +/** + * Implementors of this interface will parse pointcut expressions (e.g. {@code + * java.lang.StringBuilder java.lang.StringBuilder.append(java.lang.String)}) and return the related + * {@link MethodType} instance. + */ +public interface AdvicePointcutParser { + + @Nonnull + MethodType parse(@Nonnull String signature); + + class SignatureParsingError extends HasErrorsException { + + public SignatureParsingError(@Nonnull final HasErrors errors) { + super(errors); + } + + public SignatureParsingError(@Nonnull final Collection errors) { + super(errors); + } + + public SignatureParsingError(@Nonnull final Failure... errors) { + super(errors); + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/CallSiteReporter.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/CallSiteReporter.java new file mode 100644 index 00000000000..a2b53ce2324 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/CallSiteReporter.java @@ -0,0 +1,76 @@ +package datadog.trace.plugin.csi; + +import datadog.trace.plugin.csi.AdviceGenerator.CallSiteResult; +import freemarker.ext.beans.StringModel; +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModelException; +import java.io.PrintStream; +import java.io.StringWriter; +import java.io.Writer; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public interface CallSiteReporter { + + void report(List results, boolean error); + + static CallSiteReporter getReporter(final String type) { + if ("CONSOLE".equals(type)) { + return new ConsoleReporter(); + } + throw new IllegalArgumentException("Reporter of type '" + type + "' not supported"); + } + + abstract class FreemarkerReporter implements CallSiteReporter { + private final String template; + + protected FreemarkerReporter(final String template) { + this.template = template; + } + + protected void write(final List results, final Writer writer) { + try { + final Configuration cfg = new Configuration(Configuration.VERSION_2_3_30); + cfg.setClassLoaderForTemplateLoading(Thread.currentThread().getContextClassLoader(), "csi"); + cfg.setDefaultEncoding("UTF-8"); + final Map input = new HashMap<>(); + input.put("results", results); + input.put("toList", new ToListDirective()); + final Template template = cfg.getTemplate("console.ftl"); + template.process(input, writer); + } catch (final Exception e) { + throw new RuntimeException(e); + } + } + + private static class ToListDirective implements TemplateMethodModelEx { + + @Override + public Object exec(final List arguments) throws TemplateModelException { + final StringModel model = (StringModel) arguments.get(0); + final Stream stream = (Stream) model.getWrappedObject(); + return stream.collect(Collectors.toList()); + } + } + } + + class ConsoleReporter extends FreemarkerReporter { + + protected ConsoleReporter() { + super("console.ftl"); + } + + @Override + public void report(final List results, final boolean error) { + final PrintStream stream = error ? System.err : System.out; + final StringWriter writer = new StringWriter(); + write(results, writer); + stream.println(writer); + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/HasErrors.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/HasErrors.java new file mode 100644 index 00000000000..8c82d62796d --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/HasErrors.java @@ -0,0 +1,151 @@ +package datadog.trace.plugin.csi; + +import datadog.trace.plugin.csi.util.ErrorCode; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nonnull; + +public interface HasErrors { + + Stream getErrors(); + + void addError(@Nonnull Failure failure); + + default void addError(@Nonnull final ErrorCode error, final Object... args) { + addError(new Failure(error, args)); + } + + default void addError( + @Nonnull final Throwable cause, @Nonnull final ErrorCode error, final Object... args) { + addError(new Failure(cause, error, args)); + } + + default boolean isSuccess() { + return !getErrors().findAny().isPresent(); + } + + final class Failure { + private final ErrorCode error; + private final Object[] params; + private final Throwable cause; + + public Failure(@Nonnull final ErrorCode error, @Nonnull final Object... params) { + this.error = error; + this.params = params; + this.cause = null; + } + + public Failure( + @Nonnull final Throwable cause, + @Nonnull final ErrorCode error, + @Nonnull final Object... params) { + this.error = error; + this.params = params; + this.cause = cause; + } + + public ErrorCode getErrorCode() { + return error; + } + + public Object[] getParams() { + return params; + } + + public Throwable getCause() { + return cause; + } + + public String getCauseString() { + if (cause == null) { + return null; + } + StringWriter writer = new StringWriter(); + cause.printStackTrace(new PrintWriter(writer)); + return writer.toString(); + } + + public String getMessage() { + return error.apply(params); + } + + @Override + public String toString() { + return error.name(); + } + } + + class HasErrorsImpl implements HasErrors { + + private final List errors; + + public HasErrorsImpl(@Nonnull final Collection errors) { + this.errors = new ArrayList<>(errors); + } + + public HasErrorsImpl(@Nonnull final Failure... errors) { + this(Arrays.asList(errors)); + } + + public Stream getErrors() { + return errors.stream(); + } + + @Override + public boolean isSuccess() { + return errors.isEmpty(); + } + + @Override + public void addError(@Nonnull final Failure failure) { + errors.add(failure); + } + } + + class HasErrorsException extends RuntimeException implements HasErrors { + private final HasErrors errors; + + public HasErrorsException(@Nonnull final HasErrors errors) { + super(buildMessage(errors), firstCause(errors)); + this.errors = errors; + } + + public HasErrorsException(@Nonnull final Collection errors) { + this(new HasErrorsImpl(errors)); + } + + public HasErrorsException(@Nonnull final Failure... errors) { + this(new HasErrorsImpl(errors)); + } + + @Override + public Stream getErrors() { + return errors.getErrors(); + } + + @Override + public void addError(@Nonnull final Failure failure) { + errors.addError(failure); + } + + private static String buildMessage(@Nonnull final HasErrors errors) { + return errors.getErrors().map(Failure::getMessage).collect(Collectors.joining(" | ")); + } + + private static Throwable firstCause(@Nonnull final HasErrors errors) { + return errors + .getErrors() + .map(Failure::getCause) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/PluginApplication.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/PluginApplication.java new file mode 100644 index 00000000000..14c78078a30 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/PluginApplication.java @@ -0,0 +1,136 @@ +package datadog.trace.plugin.csi; + +import static datadog.trace.plugin.csi.impl.CallSiteFactory.adviceGenerator; +import static datadog.trace.plugin.csi.impl.CallSiteFactory.specificationBuilder; +import static datadog.trace.plugin.csi.impl.CallSiteFactory.typeResolver; + +import datadog.trace.plugin.csi.AdviceGenerator.CallSiteResult; +import datadog.trace.plugin.csi.impl.CallSiteSpecification; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class PluginApplication { + + public static void main(final String[] args) { + try { + final Path parameters = getParameters(args); + final Configuration configuration = getConfiguration(parameters); + final List specs = searchForCallSites(configuration); + final AdviceGenerator adviceGenerator = getAdviceGenerator(configuration); + final List result = + specs.stream().map(adviceGenerator::generate).collect(Collectors.toList()); + final boolean failed = result.stream().anyMatch(it -> !it.isSuccess()); + printReport(configuration, result, failed); + System.exit(failed ? 1 : 0); + } catch (final RuntimeException e) { + e.printStackTrace(System.err); + System.exit(1); + } + } + + private static void printReport( + final Configuration configuration, final List result, final boolean failed) { + configuration.reporters.forEach( + reporter -> CallSiteReporter.getReporter(reporter).report(result, failed)); + } + + private static List searchForCallSites(final Configuration configuration) { + try { + final SpecificationBuilder builder = specificationBuilder(); + final List result = new ArrayList<>(); + final Pattern pattern = Pattern.compile(".*" + configuration.suffix + "\\.class$"); + Files.walkFileTree( + configuration.classesFolder, + new SimpleFileVisitor() { + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) + throws IOException { + if (Files.isRegularFile(file) + && pattern.matcher(file.getFileName().toString()).matches()) { + builder.build(file.toFile()).ifPresent(result::add); + } + return FileVisitResult.CONTINUE; + } + }); + return result; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static AdviceGenerator getAdviceGenerator(final Configuration configuration) { + final URL[] urls = + configuration.classPath.stream().map(PluginApplication::toURL).toArray(URL[]::new); + final ClassLoader loader = new URLClassLoader(urls); + final TypeResolver resolver = typeResolver(loader); + return adviceGenerator(configuration.targetFolder.toFile(), resolver); + } + + private static URL toURL(final Path path) { + try { + return path.toUri().toURL(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + private static Configuration getConfiguration(final Path parameters) { + try { + final List lines = Files.readAllLines(parameters); + final Path classesFolder = Paths.get(lines.get(0)); + final Path targetFolder = Paths.get(lines.get(1)); + final String suffix = lines.get(2).trim(); + final List reporters = Arrays.asList(lines.get(3).trim().split(",")); + final List classPaths = + lines.stream().skip(4).map(Paths::get).collect(Collectors.toList()); + return new Configuration(classesFolder, targetFolder, classPaths, suffix, reporters); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static Path getParameters(final String[] args) { + if (args.length != 1) { + throw new IllegalArgumentException( + "The application expected a single parameter with the configuration"); + } + final Path parameters = Paths.get(args[0]); + if (!Files.exists(parameters)) { + throw new IllegalArgumentException("File '" + parameters + "' not found%n"); + } + return parameters; + } + + private static class Configuration { + private final Path classesFolder; + private final Path targetFolder; + private final List classPath; + private final String suffix; + private final List reporters; + + private Configuration( + final Path classesFolder, + final Path targetFolder, + final List classPath, + final String suffix, + final List reporters) { + this.classesFolder = classesFolder; + this.targetFolder = targetFolder; + this.classPath = classPath; + this.suffix = suffix; + this.reporters = reporters; + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/SpecificationBuilder.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/SpecificationBuilder.java new file mode 100644 index 00000000000..1fc40be8bf5 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/SpecificationBuilder.java @@ -0,0 +1,19 @@ +package datadog.trace.plugin.csi; + +import datadog.trace.plugin.csi.impl.CallSiteSpecification; +import java.io.File; +import java.util.Optional; +import javax.annotation.Nonnull; + +/** + * Implementors of this interface will take a Java class file and build the related {@link + * CallSiteSpecification} + * + *

If the class is not annotated with {@link datadog.trace.agent.tooling.csi.CallSite} + * implementors should return an empty optional + */ +public interface SpecificationBuilder { + + @Nonnull + Optional build(@Nonnull File classFile); +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/TypeResolver.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/TypeResolver.java new file mode 100644 index 00000000000..2e0698b678f --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/TypeResolver.java @@ -0,0 +1,32 @@ +package datadog.trace.plugin.csi; + +import datadog.trace.plugin.csi.HasErrors.HasErrorsException; +import datadog.trace.plugin.csi.util.MethodType; +import java.lang.reflect.Executable; +import java.util.Collection; +import javax.annotation.Nonnull; +import org.objectweb.asm.Type; + +public interface TypeResolver { + + @Nonnull + Class resolveType(@Nonnull Type type) throws ResolutionException; + + @Nonnull + Executable resolveMethod(@Nonnull MethodType method) throws ResolutionException; + + class ResolutionException extends HasErrorsException { + + public ResolutionException(@Nonnull final HasErrors errors) { + super(errors); + } + + public ResolutionException(@Nonnull final Collection errors) { + super(errors); + } + + public ResolutionException(@Nonnull final Failure... errors) { + super(errors); + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Validatable.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Validatable.java new file mode 100644 index 00000000000..9c8570caf49 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Validatable.java @@ -0,0 +1,8 @@ +package datadog.trace.plugin.csi; + +import javax.annotation.Nonnull; + +public interface Validatable { + + void validate(@Nonnull ValidationContext context); +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/ValidationContext.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/ValidationContext.java new file mode 100644 index 00000000000..719d77cc406 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/ValidationContext.java @@ -0,0 +1,27 @@ +package datadog.trace.plugin.csi; + +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nonnull; + +public interface ValidationContext extends HasErrors { + E getContextProperty(@Nonnull String name); + + void addContextProperty(@Nonnull String name, Object object); + + class BaseValidationContext extends HasErrorsImpl implements ValidationContext { + + private final Map context = new HashMap<>(); + + @SuppressWarnings("unchecked") + @Override + public final E getContextProperty(@Nonnull final String name) { + return (E) context.get(name); + } + + @Override + public final void addContextProperty(@Nonnull final String name, final Object object) { + context.put(name, object); + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilder.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilder.java new file mode 100644 index 00000000000..6b60cc67d72 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilder.java @@ -0,0 +1,282 @@ +package datadog.trace.plugin.csi.impl; + +import static datadog.trace.plugin.csi.util.CallSiteConstants.AFTER_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.AFTER_ARRAY_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.ALL_ARGS_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.ARGUMENT_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.AROUND_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.AROUND_ARRAY_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.ASM_API_VERSION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.BEFORE_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.BEFORE_ARRAY_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.CALL_SITE_ADVICE_CLASS; +import static datadog.trace.plugin.csi.util.CallSiteConstants.CALL_SITE_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.INVOKE_DYNAMIC_CONSTANTS_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.RETURN_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteConstants.THIS_ANNOTATION; +import static datadog.trace.plugin.csi.util.CallSiteUtils.classNameToDescriptor; +import static datadog.trace.plugin.csi.util.CallSiteUtils.classNameToType; +import static org.objectweb.asm.ClassReader.SKIP_CODE; +import static org.objectweb.asm.ClassReader.SKIP_DEBUG; +import static org.objectweb.asm.ClassReader.SKIP_FRAMES; + +import datadog.trace.plugin.csi.SpecificationBuilder; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AdviceSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AfterSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AllArgsSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ArgumentSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AroundSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.BeforeSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.InvokeDynamicConstantsSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ParameterSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ReturnSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ThisSpecification; +import datadog.trace.plugin.csi.util.MethodType; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nonnull; +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Type; + +/** + * Implementation of {@link SpecificationBuilder} using a {@link ClassReader} to parse the Java + * class files and build the related {@link CallSiteSpecification} instances + */ +public class AsmSpecificationBuilder implements SpecificationBuilder { + + @Override + @Nonnull + public Optional build(@Nonnull final File file) { + try (final InputStream stream = Files.newInputStream(file.toPath())) { + final ClassReader reader = new ClassReader(stream); + final SpecificationVisitor visitor = new SpecificationVisitor(); + reader.accept(visitor, SKIP_CODE | SKIP_FRAMES | SKIP_DEBUG); + return visitor.getResult(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static class SpecificationVisitor extends ClassVisitor { + + private static final String CALL_SITE = classNameToDescriptor(CALL_SITE_ANNOTATION); + + private Type clazz; + private boolean isCallSite; + private final List advices = new ArrayList<>(); + private final Set helpers = new HashSet<>(); + private Type spi = classNameToType(CALL_SITE_ADVICE_CLASS); // default annotation value + private CallSiteSpecification result; + + public SpecificationVisitor() { + super(ASM_API_VERSION); + } + + @Override + public void visit( + final int version, + final int access, + final String name, + final String signature, + final String superName, + final String[] interfaces) { + clazz = classNameToType(name); + } + + @Override + public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) { + isCallSite = CALL_SITE.equals(descriptor); + if (isCallSite) { + helpers.add(clazz); + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String key, final Object value) { + if ("spi".equals(key)) { + spi = (Type) value; + } + } + + @Override + public AnnotationVisitor visitArray(final String name) { + if ("helpers".equals(name)) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String name, final Object value) { + helpers.add((Type) value); + } + }; + } + return null; + } + }; + } + return null; + } + + @Override + public MethodVisitor visitMethod( + final int access, + final String name, + final String descriptor, + final String signature, + final String[] exceptions) { + if (isCallSite) { + return new AdviceMethodVisitor(this, name, Type.getMethodType(descriptor)); + } + return null; + } + + @Override + public void visitEnd() { + if (isCallSite) { + result = new CallSiteSpecification(clazz, advices, spi, helpers); + } + } + + public Optional getResult() { + return Optional.ofNullable(result); + } + } + + private static class AdviceMethodVisitor extends MethodVisitor { + + private static final Map ADVICE_BUILDERS = new HashMap<>(); + + private static final Set REPEATABLE_ADVICES = new HashSet<>(); + + private static final Map PARAMETER_BUILDERS = + new HashMap<>(); + + static { + ADVICE_BUILDERS.put(classNameToDescriptor(BEFORE_ANNOTATION), BeforeSpecification::new); + ADVICE_BUILDERS.put(classNameToDescriptor(AROUND_ANNOTATION), AroundSpecification::new); + ADVICE_BUILDERS.put(classNameToDescriptor(AFTER_ANNOTATION), AfterSpecification::new); + REPEATABLE_ADVICES.add(classNameToDescriptor(BEFORE_ARRAY_ANNOTATION)); + REPEATABLE_ADVICES.add(classNameToDescriptor(AROUND_ARRAY_ANNOTATION)); + REPEATABLE_ADVICES.add(classNameToDescriptor(AFTER_ARRAY_ANNOTATION)); + PARAMETER_BUILDERS.put(classNameToDescriptor(THIS_ANNOTATION), ThisSpecification::new); + PARAMETER_BUILDERS.put( + classNameToDescriptor(ARGUMENT_ANNOTATION), ArgumentSpecification::new); + PARAMETER_BUILDERS.put(classNameToDescriptor(RETURN_ANNOTATION), ReturnSpecification::new); + PARAMETER_BUILDERS.put(classNameToDescriptor(ALL_ARGS_ANNOTATION), AllArgsSpecification::new); + PARAMETER_BUILDERS.put( + classNameToDescriptor(INVOKE_DYNAMIC_CONSTANTS_ANNOTATION), + InvokeDynamicConstantsSpecification::new); + } + + private final SpecificationVisitor spec; + private final MethodType advice; + private final Map parameters = new HashMap<>(); + private final List signatures = new ArrayList<>(); + private boolean inokeDynamic; + private AdviceSpecificationCtor adviceCtor; + + public AdviceMethodVisitor( + @Nonnull final SpecificationVisitor spec, + @Nonnull final String method, + @Nonnull final Type methodType) { + super(ASM_API_VERSION); + this.spec = spec; + advice = new MethodType(spec.clazz, method, methodType); + } + + @Override + public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) { + adviceCtor = ADVICE_BUILDERS.get(descriptor); + if (adviceCtor != null) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String key, final Object value) { + if ("value".equals(key)) { + signatures.add((String) value); + } else if ("invokeDynamic".equals(key)) { + inokeDynamic = (boolean) value; + } + } + }; + } + if (REPEATABLE_ADVICES.contains(descriptor)) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public AnnotationVisitor visitArray(final String name) { + if ("value".equals(name)) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public AnnotationVisitor visitAnnotation( + final String name, final String descriptor) { + return AdviceMethodVisitor.this.visitAnnotation(descriptor, true); + } + }; + } + return null; + } + }; + } + return null; + } + + @Override + public AnnotationVisitor visitParameterAnnotation( + final int parameter, final String descriptor, final boolean visible) { + if (adviceCtor != null) { + final ParameterSpecificationCtor parameterCtor = PARAMETER_BUILDERS.get(descriptor); + if (parameterCtor != null) { + ParameterSpecification parameterSpec = parameterCtor.build(); + if (parameterSpec instanceof ArgumentSpecification) { + final long index = + parameters.values().stream() + .filter(it -> it instanceof ArgumentSpecification) + .count(); + ((ArgumentSpecification) parameterSpec).setIndex((int) index); + } + parameters.put(parameter, parameterSpec); + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String key, final Object value) { + if ("includeThis".equals(key) && parameterSpec instanceof AllArgsSpecification) { + final AllArgsSpecification allArgs = (AllArgsSpecification) parameterSpec; + allArgs.setIncludeThis((boolean) value); + } + } + }; + } + } + return null; + } + + @Override + public void visitEnd() { + if (adviceCtor != null) { + signatures.stream() + .map(sig -> adviceCtor.build(advice, parameters, sig, inokeDynamic)) + .forEach(spec.advices::add); + } + } + } + + @FunctionalInterface + private interface AdviceSpecificationCtor { + AdviceSpecification build( + @Nonnull MethodType advice, + @Nonnull Map parameters, + @Nonnull String signature, + boolean invokeDynamic); + } + + @FunctionalInterface + private interface ParameterSpecificationCtor { + ParameterSpecification build(); + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteFactory.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteFactory.java new file mode 100644 index 00000000000..b2a33a5c79f --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteFactory.java @@ -0,0 +1,38 @@ +package datadog.trace.plugin.csi.impl; + +import datadog.trace.plugin.csi.AdviceGenerator; +import datadog.trace.plugin.csi.AdvicePointcutParser; +import datadog.trace.plugin.csi.SpecificationBuilder; +import datadog.trace.plugin.csi.TypeResolver; +import java.io.File; +import javax.annotation.Nonnull; + +public abstract class CallSiteFactory { + + private CallSiteFactory() {} + + public static AdviceGenerator adviceGenerator(final File targetFolder) { + return adviceGenerator(targetFolder, typeResolver()); + } + + public static AdviceGenerator adviceGenerator( + @Nonnull final File targetFolder, @Nonnull final TypeResolver typeResolver) { + return new FreemarkerAdviceGenerator(targetFolder, pointcutParser(), typeResolver); + } + + public static SpecificationBuilder specificationBuilder() { + return new AsmSpecificationBuilder(); + } + + public static AdvicePointcutParser pointcutParser() { + return new RegexpAdvicePointcutParser(); + } + + public static TypeResolver typeResolver() { + return typeResolver(Thread.currentThread().getContextClassLoader()); + } + + public static TypeResolver typeResolver(@Nonnull final ClassLoader... classpath) { + return new TypeResolverPool(classpath); + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteSpecification.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteSpecification.java new file mode 100644 index 00000000000..519f97562ed --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteSpecification.java @@ -0,0 +1,624 @@ +package datadog.trace.plugin.csi.impl; + +import static datadog.trace.plugin.csi.util.CallSiteConstants.CALL_SITE_ADVICE_CLASS; +import static datadog.trace.plugin.csi.util.CallSiteConstants.TYPE_RESOLVER; + +import datadog.trace.plugin.csi.AdvicePointcutParser; +import datadog.trace.plugin.csi.TypeResolver; +import datadog.trace.plugin.csi.TypeResolver.ResolutionException; +import datadog.trace.plugin.csi.Validatable; +import datadog.trace.plugin.csi.ValidationContext; +import datadog.trace.plugin.csi.util.ErrorCode; +import datadog.trace.plugin.csi.util.MethodType; +import java.lang.reflect.Executable; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.BiConsumer; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import org.objectweb.asm.Type; + +/** Description of a class annotated with {@link datadog.trace.agent.tooling.csi.CallSite} */ +public class CallSiteSpecification implements Validatable { + + private final Type clazz; + private final List advices; + private final Type spi; + private final Type[] helpers; + + public CallSiteSpecification( + @Nonnull final Type clazz, + @Nonnull final List advices, + @Nonnull final Type spi, + @Nonnull final Set helpers) { + this.clazz = clazz; + this.advices = advices; + this.spi = spi; + this.helpers = helpers.toArray(new Type[0]); + } + + @Override + public void validate(@Nonnull final ValidationContext context) { + final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); + try { + if (!CALL_SITE_ADVICE_CLASS.equals(spi.getClassName())) { + Class spiClass = typeResolver.resolveType(spi); + if (!spiClass.isInterface()) { + context.addError(ErrorCode.CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE, spiClass); + } else { + if (spiClass.getDeclaredMethods().length > 0) { + context.addError(ErrorCode.CALL_SITE_SPI_SHOULD_BE_EMPTY, spiClass); + } + } + } + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + if (advices.isEmpty()) { + context.addError(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS); + } + } + + public Type getClazz() { + return clazz; + } + + public Type getSpi() { + return spi; + } + + public Type[] getHelpers() { + return helpers; + } + + public Stream getAdvices() { + return advices.stream(); + } + + /** + * Description of a method annotated with {@link datadog.trace.agent.tooling.csi.CallSite.After}, + * {@link datadog.trace.agent.tooling.csi.CallSite.Before} or {@link + * datadog.trace.agent.tooling.csi.CallSite.Around} + */ + public abstract static class AdviceSpecification implements Validatable { + + protected final MethodType advice; + private final Map parameters; + protected final String signature; + protected final boolean invokeDynamic; + protected MethodType pointcut; + protected Executable pointcutMethod; + + public AdviceSpecification( + @Nonnull final MethodType advice, + @Nonnull final Map parameters, + @Nonnull final String signature, + final boolean invokeDynamic) { + this.advice = advice; + this.parameters = new TreeMap<>(parameters); + this.signature = signature; + this.invokeDynamic = invokeDynamic; + } + + @Override + public void validate(@Nonnull final ValidationContext context) { + validatePointcut(context); + validateAdvice(context); + validateCompatibility(context); + } + + protected void validateCompatibility(final ValidationContext context) { + try { + final Type[] adviceArgumentTypes = advice.getMethodType().getArgumentTypes(); + final Set pointcutParameters = new HashSet<>(); + for (int i = 0; i < pointcut.getMethodType().getArgumentTypes().length; i++) { + pointcutParameters.add(i); + } + validateAllArgsSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); + if (isInvokeDynamic()) { + validateInvokeDynamicConstCompatibility(context, adviceArgumentTypes, pointcutParameters); + if (this instanceof AroundSpecification) { + validateArgumentSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); + } + } else { + validateAdviceReturnTypeCompatibility(context); + validateThisSpecCompatibility(context, adviceArgumentTypes); + validateArgumentSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); + validateReturnSpecCompatibility(context, adviceArgumentTypes); + if (!pointcutParameters.isEmpty()) { + context.addError( + ErrorCode.ADVICE_POINT_CUT_PARAMETERS_NOT_CONSUMED, pointcutParameters); + } + } + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + } + + private void validateArgumentSpecCompatibility( + final ValidationContext context, + final Type[] adviceArgumentTypes, + final Set pointcutParameters) { + withParameter( + ArgumentSpecification.class, + (i, spec) -> { + final Type argType = pointcut.getMethodType().getArgumentTypes()[spec.index]; + final Type advice = adviceArgumentTypes[i]; + if (!pointcutParameters.remove(spec.index)) { + context.addError(ErrorCode.ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS); + } + validateCompatibility( + context, argType, advice, ErrorCode.ADVICE_METHOD_PARAM_NOT_COMPATIBLE, i); + }); + } + + private void validateReturnSpecCompatibility( + final ValidationContext context, final Type[] adviceArgumentTypes) { + withParameter( + ReturnSpecification.class, + (i, spec) -> { + final Type rType = pointcut.getMethodType().getReturnType(); + final Type advice = adviceArgumentTypes[i]; + validateCompatibility( + context, rType, advice, ErrorCode.ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE, i); + }); + } + + private void validateThisSpecCompatibility( + final ValidationContext context, final Type[] adviceArgumentTypes) { + withParameter( + ThisSpecification.class, + (i, spec) -> { + final Type owner = pointcut.getOwner(); + final Type advice = adviceArgumentTypes[i]; + validateCompatibility( + context, owner, advice, ErrorCode.ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE, i); + }); + } + + private void validateAdviceReturnTypeCompatibility(final ValidationContext context) { + if (!advice.isVoidReturn()) { + final Type pointcutType = + pointcut.isConstructor() + ? pointcut.getOwner() + : pointcut.getMethodType().getReturnType(); + final Type adviceType = advice.getMethodType().getReturnType(); + validateCompatibility( + context, pointcutType, adviceType, ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE, -1); + } + } + + private void validateInvokeDynamicConstCompatibility( + final ValidationContext context, + final Type[] adviceArgumentTypes, + final Set pointcutParameters) { + withParameter( + InvokeDynamicConstantsSpecification.class, + (i, spec) -> { + final Type type = Type.getType(Object[].class); + final Type advice = adviceArgumentTypes[i]; + pointcutParameters.clear(); + validateCompatibility( + context, + type, + advice, + ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NOT_COMPATIBLE, + i); + }); + } + + private void validateAllArgsSpecCompatibility( + final ValidationContext context, + final Type[] adviceArgumentTypes, + final Set pointcutParameters) { + withParameter( + AllArgsSpecification.class, + (i, spec) -> { + final Type type = Type.getType(Object[].class); + final Type advice = adviceArgumentTypes[i]; + pointcutParameters.clear(); + validateCompatibility( + context, type, advice, ErrorCode.ADVICE_METHOD_PARAM_ALL_ARGS_NOT_COMPATIBLE, i); + }); + } + + protected void validateCompatibility( + final ValidationContext context, + final Type pointcutType, + final Type adviceType, + final ErrorCode errorCode, + final int index) { + final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); + if (!typeResolver + .resolveType(adviceType) + .isAssignableFrom(typeResolver.resolveType(pointcutType))) { + context.addError(errorCode, pointcutType, adviceType, index); + } + } + + protected void validateAdvice(@Nonnull final ValidationContext context) { + try { + validateAdviceParameters(context); + final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); + final Method executable = (Method) typeResolver.resolveMethod(advice); + final int access = executable.getModifiers(); + if (!Modifier.isPublic(access) || !Modifier.isStatic(access)) { + context.addError(ErrorCode.ADVICE_METHOD_NOT_STATIC_AND_PUBLIC, this); + } + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + } + + protected void validateAdviceParameters(@Nonnull final ValidationContext context) { + final Type[] adviceArguments = advice.getMethodType().getArgumentTypes(); + boolean thisFound = false, + returnFound = false, + allArgsFound = false, + argumentFound = false, + dynamicConstantsFound = false; + for (int i = 0; i < adviceArguments.length; i++) { + ParameterSpecification spec = parameters.get(i); + if (spec == null) { + context.addError(ErrorCode.ADVICE_PARAMETER_NOT_ANNOTATED, i); + } else { + if (spec instanceof ThisSpecification) { + validateThisSpec(context, thisFound, i); + thisFound = true; + } else if (spec instanceof ReturnSpecification) { + validateReturnSpec(context, returnFound, i); + returnFound = true; + } else if (spec instanceof AllArgsSpecification) { + validateAllArgsSpec(context, allArgsFound, argumentFound, i); + allArgsFound = true; + } else if (spec instanceof InvokeDynamicConstantsSpecification) { + validateInvokeDynamicConstSpec(context, dynamicConstantsFound, i); + dynamicConstantsFound = true; + } else { + validateArgumentSpec(context, allArgsFound, i); + argumentFound = true; + } + } + } + } + + private void validateArgumentSpec( + final ValidationContext context, final boolean allArgsFound, final int i) { + if (allArgsFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED, i); + } + if (isInvokeDynamic() && !(this instanceof AroundSpecification)) { + context.addError(ErrorCode.ADVICE_PARAMETER_ON_INVOKE_DYNAMIC, i); + } + } + + private void validateInvokeDynamicConstSpec( + final ValidationContext context, final boolean dynamicConstantsFound, final int i) { + if (dynamicConstantsFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_DUPLICATED, i); + } + if (!isInvokeDynamic()) { + context.addError( + ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC, i); + } + if (!(this instanceof AfterSpecification)) { + context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE, i); + } + final Type[] arguments = advice.getMethodType().getArgumentTypes(); + if (i != arguments.length - 1) { + context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST, i); + } + } + + private void validateAllArgsSpec( + final ValidationContext context, + final boolean allArgsFound, + final boolean argumentFound, + final int i) { + if (allArgsFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_DUPLICATED, i); + } + if (argumentFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED, i); + } + } + + private void validateReturnSpec( + final ValidationContext context, final boolean returnFound, final int i) { + if (returnFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_DUPLICATED, i); + } + final Type[] arguments = advice.getMethodType().getArgumentTypes(); + if (i != arguments.length - 1) { + if (isInvokeDynamic()) { + if (i != arguments.length - 2 + && !(parameters.get(arguments.length - 1) + instanceof InvokeDynamicConstantsSpecification)) {} + + } else { + context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST, i); + } + } + if (!(this instanceof AfterSpecification)) { + context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_NON_AFTER_ADVICE, i); + } + } + + private void validateThisSpec( + final ValidationContext context, final boolean thisFound, final int i) { + if (thisFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_THIS_DUPLICATED, i); + } + if (i != 0) { + context.addError(ErrorCode.ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST, i); + } + if (isStaticPointcut()) { + context.addError(ErrorCode.ADVICE_PARAMETER_THIS_ON_STATIC_METHOD, i); + } + if (isInvokeDynamic()) { + context.addError(ErrorCode.ADVICE_PARAMETER_THIS_ON_INVOKE_DYNAMIC, i); + } + } + + protected void validatePointcut(@Nonnull final ValidationContext context) { + if (pointcut != null) { + try { + final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); + if (pointcut.isConstructor() && !pointcut.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_POINTCUT_CONSTRUCTOR_NOT_VOID, pointcut); + } + pointcutMethod = typeResolver.resolveMethod(pointcut); + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + } + } + + public void parseSignature(@Nonnull final AdvicePointcutParser parser) { + pointcut = parser.parse(signature); + } + + public MethodType getAdvice() { + return advice; + } + + public MethodType getPointcut() { + return pointcut; + } + + public String getSignature() { + return signature; + } + + public boolean isStaticPointcut() { + return pointcutMethod != null && Modifier.isStatic(pointcutMethod.getModifiers()); + } + + public boolean isInvokeDynamic() { + return invokeDynamic; + } + + public boolean includeThis() { + if (findThis() != null) { + return true; + } + final AllArgsSpecification allArgs = findAllArguments(); + return allArgs != null && allArgs.includeThis; + } + + public ThisSpecification findThis() { + return findParameter(ThisSpecification.class); + } + + public ReturnSpecification findReturn() { + return findParameter(ReturnSpecification.class); + } + + public AllArgsSpecification findAllArguments() { + return findParameter(AllArgsSpecification.class); + } + + public InvokeDynamicConstantsSpecification findInvokeDynamicConstants() { + return findParameter(InvokeDynamicConstantsSpecification.class); + } + + private void withParameter( + final Class spec, final BiConsumer consumer) { + parameters.entrySet().stream() + .filter(entry -> spec.isInstance(entry.getValue())) + .forEach(entry -> consumer.accept(entry.getKey(), spec.cast(entry.getValue()))); + } + + private E findParameter(final Class spec) { + return parameters.values().stream() + .filter(spec::isInstance) + .map(spec::cast) + .findFirst() + .orElse(null); + } + + public boolean isConstructor() { + return pointcut.isConstructor(); + } + + public Stream getArguments() { + return parameters.values().stream() + .filter(it -> it instanceof ArgumentSpecification) + .map(it -> (ArgumentSpecification) it); + } + + public boolean isComputeMaxStack() { + return !(this instanceof AroundSpecification); + } + } + + public static final class BeforeSpecification extends AdviceSpecification { + public BeforeSpecification( + @Nonnull final MethodType advice, + @Nonnull final Map parameters, + @Nonnull final String signature, + final boolean invokeDynamic) { + super(advice, parameters, signature, invokeDynamic); + } + + @Override + protected void validateAdvice(@Nonnull final ValidationContext context) { + if (!advice.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_BEFORE_SHOULD_RETURN_VOID, advice); + } + if (findReturn() != null) { + context.addError(ErrorCode.ADVICE_BEFORE_SHOULD_NOT_CONTAIN_RETURN); + } + if (pointcut.isConstructor() && includeThis()) { + context.addError(ErrorCode.ADVICE_BEFORE_CTOR_SHOULD_NOT_CONTAIN_THIS); + } + super.validateAdvice(context); + } + + @Override + public String toString() { + return "@CallSite.Before(" + signature + ")"; + } + } + + public static final class AroundSpecification extends AdviceSpecification { + public AroundSpecification( + @Nonnull final MethodType advice, + @Nonnull final Map parameters, + @Nonnull final String signature, + final boolean invokeDynamic) { + super(advice, parameters, signature, invokeDynamic); + } + + @Override + protected void validateAdvice(@Nonnull final ValidationContext context) { + if (advice.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_AROUND_SHOULD_NOT_RETURN_VOID); + } + if (findReturn() != null) { + context.addError(ErrorCode.ADVICE_AROUND_SHOULD_NOT_CONTAIN_RETURN); + } + super.validateAdvice(context); + } + + @Override + protected void validatePointcut(@Nonnull final ValidationContext context) { + if (pointcut.isConstructor()) { + context.addError(ErrorCode.ADVICE_AROUND_POINTCUT_CTOR); + } + super.validatePointcut(context); + } + + @Override + public String toString() { + return "@CallSite.Around(" + signature + ")"; + } + } + + public static final class AfterSpecification extends AdviceSpecification { + public AfterSpecification( + @Nonnull final MethodType advice, + @Nonnull final Map parameters, + @Nonnull final String signature, + final boolean invokeDynamic) { + super(advice, parameters, signature, invokeDynamic); + } + + @Override + protected void validateAdvice(@Nonnull final ValidationContext context) { + if (advice.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_AFTER_SHOULD_NOT_RETURN_VOID); + } + if (!isStaticPointcut() && !includeThis()) { + context.addError(ErrorCode.ADVICE_AFTER_SHOULD_HAVE_THIS); + } + if (!pointcut.isConstructor()) { + if (findReturn() == null) { + context.addError(ErrorCode.ADVICE_AFTER_SHOULD_HAVE_RETURN); + } + } else { + if (findReturn() != null) { + context.addError(ErrorCode.ADVICE_AFTER_CONSTRUCTOR_SHOULD_NOT_HAVE_RETURN); + } + } + super.validateAdvice(context); + } + + @Override + public String toString() { + return "@CallSite.After(" + signature + ")"; + } + } + + /** + * Description of a method parameter annotated with: {@link + * datadog.trace.agent.tooling.csi.CallSite.This}, {@link + * datadog.trace.agent.tooling.csi.CallSite.Argument}, {@link + * datadog.trace.agent.tooling.csi.CallSite.AllArguments}, {@link + * datadog.trace.agent.tooling.csi.CallSite.InvokeDynamicConstants}, {@link + * datadog.trace.agent.tooling.csi.CallSite.Return} + */ + public abstract static class ParameterSpecification {} + + public static final class ThisSpecification extends ParameterSpecification { + @Override + public String toString() { + return "@This"; + } + } + + public static final class ReturnSpecification extends ParameterSpecification { + @Override + public String toString() { + return "@Return"; + } + } + + public static final class AllArgsSpecification extends ParameterSpecification { + + private boolean includeThis; + + public boolean isIncludeThis() { + return includeThis; + } + + public void setIncludeThis(boolean includeThis) { + this.includeThis = includeThis; + } + + @Override + public String toString() { + return "@AllArguments(includeThis=" + includeThis + ")"; + } + } + + public static final class ArgumentSpecification extends ParameterSpecification { + + private int index; + + public int getIndex() { + return index; + } + + public void setIndex(final int index) { + this.index = index; + } + + @Override + public String toString() { + return "@Argument"; + } + } + + public static final class InvokeDynamicConstantsSpecification extends ParameterSpecification { + @Override + public String toString() { + return "@InvokeDynamicConstants"; + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/FreemarkerAdviceGenerator.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/FreemarkerAdviceGenerator.java new file mode 100644 index 00000000000..4845b3801a7 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/FreemarkerAdviceGenerator.java @@ -0,0 +1,275 @@ +package datadog.trace.plugin.csi.impl; + +import static datadog.trace.plugin.csi.impl.CallSiteFactory.typeResolver; +import static datadog.trace.plugin.csi.util.CallSiteConstants.TYPE_RESOLVER; +import static datadog.trace.plugin.csi.util.CallSiteUtils.capitalize; +import static datadog.trace.plugin.csi.util.CallSiteUtils.classNameToType; +import static datadog.trace.plugin.csi.util.CallSiteUtils.createNewFile; +import static datadog.trace.plugin.csi.util.CallSiteUtils.deleteFile; + +import datadog.trace.plugin.csi.AdviceGenerator; +import datadog.trace.plugin.csi.AdvicePointcutParser; +import datadog.trace.plugin.csi.HasErrors; +import datadog.trace.plugin.csi.TypeResolver; +import datadog.trace.plugin.csi.ValidationContext; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AdviceSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AfterSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AllArgsSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AroundSpecification; +import datadog.trace.plugin.csi.impl.CallSiteSpecification.BeforeSpecification; +import datadog.trace.plugin.csi.util.ErrorCode; +import datadog.trace.plugin.csi.util.MethodType; +import freemarker.template.Configuration; +import freemarker.template.Template; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import org.objectweb.asm.Type; + +/** + * Implementation of {@link AdviceGenerator} that uses Freemarker to build the Java files with the + * {@link datadog.trace.agent.tooling.csi.CallSiteAdvice} implementation + */ +public class FreemarkerAdviceGenerator implements AdviceGenerator { + + private static final int APPLY_METHOD_BODY_BUFFER = 512; + private static final String TAB = " "; + private static final char LINE_END = '\n'; + + private final File targetFolder; + private final AdvicePointcutParser pointcutParser; + private final TypeResolver typeResolver; + + private final Template template; + + public FreemarkerAdviceGenerator( + @Nonnull final File targetFolder, @Nonnull final AdvicePointcutParser pointcutParser) { + this(targetFolder, pointcutParser, typeResolver()); + } + + public FreemarkerAdviceGenerator( + @Nonnull final File targetFolder, + @Nonnull final AdvicePointcutParser pointcutParser, + @Nonnull final TypeResolver typeResolver) { + this.targetFolder = targetFolder; + this.pointcutParser = pointcutParser; + this.typeResolver = typeResolver; + template = createTemplate(); + } + + @Override + @Nonnull + public CallSiteResult generate(@Nonnull final CallSiteSpecification spec) { + final CallSiteResult result = new CallSiteResult(spec); + result.addContextProperty(TYPE_RESOLVER, typeResolver); + try { + spec.validate(result); + if (result.isSuccess()) { + Map> advices = groupAdvicesByMethod(spec); + for (List list : advices.values()) { + final boolean unique = list.size() == 1; + for (int i = 0; i < list.size(); i++) { + final AdviceSpecification advice = list.get(i); + final String className = + String.format( + "%s%s%s", + spec.getClazz().getClassName(), + capitalize(advice.getAdvice().getMethodName()), + unique ? "" : i); + result.addAdvice( + generateAdviceJavaFile( + spec.getSpi(), spec.getHelpers(), advice, classNameToType(className))); + } + } + } + } catch (Throwable e) { + handleThrowable(result, e); + } + return result; + } + + private Map> groupAdvicesByMethod( + @Nonnull final CallSiteSpecification spec) { + return spec.getAdvices() + .collect(Collectors.groupingBy(advice -> advice.getAdvice().getMethodName())); + } + + private AdviceResult generateAdviceJavaFile( + @Nonnull final Type spiClass, + @Nonnull final Type[] helperClasses, + @Nonnull final AdviceSpecification spec, + @Nonnull final Type adviceClass) { + final File javaFile = new File(targetFolder, adviceClass.getInternalName() + ".java"); + final AdviceResult result = new AdviceResult(spec, javaFile); + result.addContextProperty(TYPE_RESOLVER, typeResolver); + createNewFile(javaFile); + try (Writer writer = new FileWriter(javaFile)) { + spec.parseSignature(pointcutParser); + spec.validate(result); + if (!result.isSuccess()) { + deleteFile(javaFile); + return result; + } + final Map arguments = new HashMap<>(); + arguments.put("spiPackageName", getPackageName(spiClass)); + arguments.put("spiClassName", getClassName(spiClass, false)); + arguments.put("packageName", getPackageName(adviceClass)); + arguments.put("className", getClassName(adviceClass)); + arguments.put("dynamicInvoke", spec.isInvokeDynamic()); + arguments.put("computeMaxStack", spec.isComputeMaxStack()); + arguments.put("applyBody", getApplyMethodBody(spec)); + arguments.put("helperClassNames", getHelperClassNames(helperClasses)); + final MethodType pointcut = spec.getPointcut(); + arguments.put("type", pointcut.getOwner().getInternalName()); + arguments.put("method", pointcut.getMethodName()); + arguments.put("methodDescriptor", pointcut.getMethodType().getDescriptor()); + template.process(arguments, writer); + } catch (Throwable e) { + deleteFile(javaFile); + handleThrowable(result, e); + } + return result; + } + + private Set getHelperClassNames(final Type[] spec) { + return Arrays.stream(spec).map(Type::getClassName).collect(Collectors.toSet()); + } + + private String getApplyMethodBody(@Nonnull final AdviceSpecification spec) { + final StringBuilder builder = new StringBuilder(APPLY_METHOD_BODY_BUFFER); + if (spec instanceof BeforeSpecification) { + writeStackOperations(builder, spec); + writeAdviceMethodCall(builder, spec); + writeOriginalMethodCall(builder, spec); + } else if (spec instanceof AfterSpecification) { + writeStackOperations(builder, spec); + writeOriginalMethodCall(builder, spec); + writeAdviceMethodCall(builder, spec); + } else { + writeAdviceMethodCall(builder, spec); + } + return builder.toString(); + } + + private void writeStackOperations( + @Nonnull final StringBuilder builder, @Nonnull final AdviceSpecification advice) { + final AllArgsSpecification allArgsSpec = advice.findAllArguments(); + final String mode; + if (allArgsSpec != null) { + mode = advice instanceof AfterSpecification ? "PREPEND_ARRAY" : "APPEND_ARRAY"; + } else { + mode = "COPY"; + } + builder.append(TAB).append(TAB).append("handler."); + if (advice.includeThis()) { + builder.append("dupInvoke(owner, descriptor"); + } else { + builder.append("dupParameters(descriptor"); + } + builder.append(", StackDupMode.").append(mode).append(");").append(LINE_END); + } + + private void writeOriginalMethodCall( + @Nonnull final StringBuilder builder, @Nonnull final AdviceSpecification advice) { + builder.append(TAB).append(TAB); + if (advice.isInvokeDynamic()) { + builder.append( + "handler.invokeDynamic(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);"); + } else { + builder.append("handler.method(opcode, owner, name, descriptor, isInterface);"); + } + builder.append(LINE_END); + } + + private static void writeAdviceMethodCall( + @Nonnull final StringBuilder builder, @Nonnull final AdviceSpecification advice) { + final MethodType method = advice.getAdvice(); + if (advice instanceof AroundSpecification && advice.invokeDynamic) { + builder + .append(TAB) + .append(TAB) + .append("handler.invokeDynamic(name, descriptor, new Handle(Opcodes.H_INVOKESTATIC, \"") + .append(method.getOwner().getInternalName()) + .append("\", \"") + .append(method.getMethodName()) + .append("\", \"") + .append(method.getMethodType().getDescriptor()) + .append("\", false), ") + .append("bootstrapMethodArguments);") + .append(LINE_END); + } else { + if (advice.isInvokeDynamic() && advice.findInvokeDynamicConstants() != null) { + // we should add the boostrap method constants before the method call + builder + .append(TAB) + .append(TAB) + .append("handler.loadConstantArray(bootstrapMethodArguments);") + .append(LINE_END); + } + builder + .append(TAB) + .append(TAB) + .append("handler.method(Opcodes.INVOKESTATIC, \"") + .append(method.getOwner().getInternalName()) + .append("\", \"") + .append(method.getMethodName()) + .append("\", \"") + .append(method.getMethodType().getDescriptor()) + .append("\", false);") + .append(LINE_END); + if (advice instanceof AfterSpecification && advice.isConstructor()) { + // constructors make a DUP before the that we have to discard + builder + .append(TAB) + .append(TAB) + .append("handler.instruction(Opcodes.POP);") + .append(LINE_END); + } + } + } + + private static String getPackageName(final Type type) { + final String className = type.getClassName(); + final int index = type.getClassName().lastIndexOf('.'); + return index >= 0 ? className.substring(0, index) : null; + } + + private static String getClassName(final Type type) { + return getClassName(type, true); + } + + private static String getClassName(final Type type, final boolean definition) { + final String className = type.getClassName(); + final int index = type.getClassName().lastIndexOf('.'); + final String result = index >= 0 ? className.substring(index + 1) : className; + return definition ? result : result.replace('$', '.'); + } + + private static void handleThrowable( + @Nonnull final ValidationContext container, @Nonnull final Throwable t) { + if (t instanceof HasErrors) { + ((HasErrors) t).getErrors().forEach(container::addError); + } else { + container.addError(t, ErrorCode.UNCAUGHT_ERROR); + } + } + + private static Template createTemplate() { + try { + final Configuration cfg = new Configuration(Configuration.VERSION_2_3_30); + cfg.setClassLoaderForTemplateLoading(Thread.currentThread().getContextClassLoader(), "csi"); + cfg.setDefaultEncoding("UTF-8"); + return cfg.getTemplate("advice.ftl"); + } catch (IOException e) { + throw new IllegalArgumentException("Template not found", e); + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParser.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParser.java new file mode 100644 index 00000000000..d3205dfd0d3 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParser.java @@ -0,0 +1,142 @@ +package datadog.trace.plugin.csi.impl; + +import static datadog.trace.plugin.csi.util.CallSiteUtils.classNameToDescriptor; +import static datadog.trace.plugin.csi.util.CallSiteUtils.classNameToType; +import static datadog.trace.plugin.csi.util.CallSiteUtils.repeat; + +import datadog.trace.plugin.csi.AdvicePointcutParser; +import datadog.trace.plugin.csi.HasErrors; +import datadog.trace.plugin.csi.HasErrors.Failure; +import datadog.trace.plugin.csi.HasErrors.HasErrorsImpl; +import datadog.trace.plugin.csi.util.ErrorCode; +import datadog.trace.plugin.csi.util.MethodType; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.annotation.Nonnull; +import org.objectweb.asm.Type; + +/** + * Implementation of {@link AdvicePointcutParser} using a simple regexp expression to extract the + * {@link MethodType} of the pointcut + */ +public class RegexpAdvicePointcutParser implements AdvicePointcutParser { + + private static final Pattern ADVICE_SIGNATURE_PATTERN = + Pattern.compile("^(?\\S*)\\s+(?\\S*)\\.(?\\S*)\\s*\\((?.*)\\)$"); + private static final char ARRAY_DESCRIPTOR = '['; + private static final Map PRIMITIVE_TYPES = new HashMap<>(9); + + static { + PRIMITIVE_TYPES.put("byte", Type.BYTE_TYPE); + PRIMITIVE_TYPES.put("char", Type.CHAR_TYPE); + PRIMITIVE_TYPES.put("double", Type.DOUBLE_TYPE); + PRIMITIVE_TYPES.put("float", Type.FLOAT_TYPE); + PRIMITIVE_TYPES.put("int", Type.INT_TYPE); + PRIMITIVE_TYPES.put("long", Type.LONG_TYPE); + PRIMITIVE_TYPES.put("short", Type.SHORT_TYPE); + PRIMITIVE_TYPES.put("boolean", Type.BOOLEAN_TYPE); + PRIMITIVE_TYPES.put("void", Type.VOID_TYPE); + } + + @Override + @Nonnull + public MethodType parse(@Nonnull final String signature) { + final Matcher matcher = ADVICE_SIGNATURE_PATTERN.matcher(signature.trim()); + if (!matcher.matches()) { + final String pattern = ADVICE_SIGNATURE_PATTERN.pattern(); + throw new SignatureParsingError( + new Failure(ErrorCode.POINTCUT_SIGNATURE_INVALID, signature, pattern)); + } + final HasErrors errors = new HasErrorsImpl(); + final Type target = parseTarget(signature, matcher, errors); + final Type returnType = parseReturn(signature, matcher, errors); + final Type[] argTypes = parseArguments(signature, matcher, errors); + if (!errors.isSuccess()) { + throw new SignatureParsingError(errors); + } + final String method = matcher.group("method").trim(); + assert target != null && returnType != null; + return new MethodType(target, method, Type.getMethodType(returnType, argTypes)); + } + + private static Type parseTarget( + @Nonnull final String signature, + @Nonnull final Matcher matcher, + @Nonnull final HasErrors errors) { + final String typeName = matcher.group("type"); + try { + return parseType(typeName); + } catch (Throwable e) { + errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, typeName); + return null; + } + } + + private static Type parseReturn( + @Nonnull final String signature, + @Nonnull final Matcher matcher, + @Nonnull final HasErrors errors) { + final String returnTypeName = matcher.group("return").trim(); + try { + return parseType(returnTypeName); + } catch (Throwable e) { + errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, returnTypeName); + return null; + } + } + + private static Type[] parseArguments( + @Nonnull final String signature, + @Nonnull final Matcher matcher, + @Nonnull final HasErrors errors) { + final String argsGroup = matcher.group("args"); + final String names = argsGroup == null ? "" : argsGroup.trim(); + if (names.isEmpty()) { + return new Type[0]; + } + String[] argNames = names.split(","); + Type[] result = new Type[argNames.length]; + for (int i = 0; i < argNames.length; i++) { + String argTypeName = argNames[i]; + try { + result[i] = parseType(argTypeName.trim()); + } catch (Throwable e) { + errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, argTypeName); + } + } + return result; + } + + private static Type parseType(final String name) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("Type name cannot be null or empty"); + } + int startOfArray = name.indexOf(ARRAY_DESCRIPTOR); + if (startOfArray >= 0) { + final Type arrayType = parseType(name.substring(0, startOfArray)); + String arrayDeclaration = name.substring(startOfArray); + int dimension = + (int) + arrayDeclaration + .chars() + .filter(it -> it == ARRAY_DESCRIPTOR) + .count(); // assumes array notation is well-formed + String elementType = + arrayType.getSort() == Type.OBJECT + ? classNameToDescriptor(arrayType.getClassName()) + : arrayType.getInternalName(); + return Type.getType(repeat(ARRAY_DESCRIPTOR, dimension) + elementType); + } + return classNameOrPrimitiveToType(name); + } + + public static Type classNameOrPrimitiveToType(final String name) { + if (name == null || name.isEmpty()) { + return null; + } + final Type type = PRIMITIVE_TYPES.get(name); + return type != null ? type : classNameToType(name); + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/TypeResolverPool.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/TypeResolverPool.java new file mode 100644 index 00000000000..cc17195f154 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/TypeResolverPool.java @@ -0,0 +1,116 @@ +package datadog.trace.plugin.csi.impl; + +import static datadog.trace.plugin.csi.util.CallSiteUtils.repeat; + +import datadog.trace.plugin.csi.HasErrors.Failure; +import datadog.trace.plugin.csi.TypeResolver; +import datadog.trace.plugin.csi.util.ErrorCode; +import datadog.trace.plugin.csi.util.MethodType; +import java.lang.reflect.Executable; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import org.objectweb.asm.Type; + +public class TypeResolverPool implements TypeResolver { + + private final List classpath; + private final Map> resolvedTypes = new HashMap<>(); + private final Map resolvedMethods = new HashMap<>(); + + public TypeResolverPool() { + this(Thread.currentThread().getContextClassLoader()); + } + + public TypeResolverPool(@Nonnull final ClassLoader... classpath) { + this.classpath = Arrays.asList(classpath); + resolvedTypes.put(Type.BYTE_TYPE, byte.class); + resolvedTypes.put(Type.CHAR_TYPE, char.class); + resolvedTypes.put(Type.DOUBLE_TYPE, double.class); + resolvedTypes.put(Type.FLOAT_TYPE, float.class); + resolvedTypes.put(Type.INT_TYPE, int.class); + resolvedTypes.put(Type.LONG_TYPE, long.class); + resolvedTypes.put(Type.SHORT_TYPE, short.class); + resolvedTypes.put(Type.BOOLEAN_TYPE, boolean.class); + resolvedTypes.put(Type.VOID_TYPE, void.class); + } + + @Override + @Nonnull + public Class resolveType(@Nonnull final Type type) { + return resolvedTypes.computeIfAbsent(type, this::resolveNewType); + } + + private Class resolveNewType(@Nonnull final Type type) { + if (type.getSort() == Type.METHOD) { + throw new IllegalArgumentException(type + " is a method"); + } + Throwable cause = null; + final String className = getResolvableClassName(type); + for (ClassLoader classLoader : classpath) { + try { + return Class.forName(className, true, classLoader); + } catch (Throwable t) { + cause = t; + } + } + if (cause == null) { + throw new ResolutionException(new Failure(ErrorCode.UNRESOLVED_TYPE, type)); + } else { + throw new ResolutionException(new Failure(cause, ErrorCode.UNRESOLVED_TYPE, type)); + } + } + + private String getResolvableClassName(final Type type) { + switch (type.getSort()) { + case Type.ARRAY: + Type element = type.getElementType(); + String elementClassName = + element.getSort() == Type.OBJECT + ? "L" + element.getClassName() + ";" + : element.getInternalName(); + return repeat('[', type.getDimensions()) + elementClassName; + case Type.OBJECT: + return type.getClassName(); + default: + throw new IllegalArgumentException( + "Primitive types should have already been resolved " + type); + } + } + + @Override + @Nonnull + public Executable resolveMethod(@Nonnull final MethodType method) { + return resolvedMethods.computeIfAbsent(method, this::resolveNewMethod); + } + + private Executable resolveNewMethod(@Nonnull final MethodType method) { + final Class owner = resolveType(method.getOwner()); + final Type[] argumentTypes = method.getMethodType().getArgumentTypes(); + final Class[] arguments = new Class[argumentTypes.length]; + for (int i = 0; i < argumentTypes.length; i++) { + arguments[i] = resolveType(argumentTypes[i]); + } + try { + if (method.isConstructor()) { + try { + return owner.getDeclaredConstructor(arguments); + } catch (final NoSuchMethodException e) { + return owner.getConstructor(arguments); + } + } else { + try { + return owner.getDeclaredMethod(method.getMethodName(), arguments); + } catch (final NoSuchMethodException e) { + return owner.getMethod(method.getMethodName(), arguments); + } + } + } catch (final ResolutionException e) { + throw e; + } catch (final Throwable e) { + throw new ResolutionException(new Failure(e, ErrorCode.UNRESOLVED_METHOD, method)); + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteConstants.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteConstants.java new file mode 100644 index 00000000000..fdb828d4a82 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteConstants.java @@ -0,0 +1,36 @@ +package datadog.trace.plugin.csi.util; + +import org.objectweb.asm.Opcodes; + +public abstract class CallSiteConstants { + private CallSiteConstants() {} + + public static final String CALL_SITE_PACKAGE = "datadog.trace.agent.tooling.csi"; + public static final String CALL_SITE_ANNOTATION = CALL_SITE_PACKAGE + ".CallSite"; + public static final String BEFORE_ANNOTATION = CALL_SITE_ANNOTATION + "$Before"; + public static final String BEFORE_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$BeforeArray"; + public static final String AROUND_ANNOTATION = CALL_SITE_ANNOTATION + "$Around"; + public static final String AROUND_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$AroundArray"; + public static final String AFTER_ANNOTATION = CALL_SITE_ANNOTATION + "$After"; + public static final String AFTER_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$AfterArray"; + public static final String THIS_ANNOTATION = CALL_SITE_ANNOTATION + "$This"; + public static final String ALL_ARGS_ANNOTATION = CALL_SITE_ANNOTATION + "$AllArguments"; + + public static final String INVOKE_DYNAMIC_CONSTANTS_ANNOTATION = + CALL_SITE_ANNOTATION + "$InvokeDynamicConstants"; + + public static final String ARGUMENT_ANNOTATION = CALL_SITE_ANNOTATION + "$Argument"; + public static final String RETURN_ANNOTATION = CALL_SITE_ANNOTATION + "$Return"; + public static final String CALL_SITE_ADVICE_CLASS = CALL_SITE_PACKAGE + ".CallSiteAdvice"; + + public static final String CONSTRUCTOR_METHOD = ""; + + /** + * {@link datadog.trace.plugin.csi.ValidationContext} property name for the {@link + * datadog.trace.plugin.csi.TypeResolver} + */ + public static final String TYPE_RESOLVER = "typeResolver"; + + /** ASM version to use in all CSI related tasks */ + public static final int ASM_API_VERSION = Opcodes.ASM7; +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteUtils.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteUtils.java new file mode 100644 index 00000000000..9ae819981bf --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteUtils.java @@ -0,0 +1,70 @@ +package datadog.trace.plugin.csi.util; + +import java.io.File; +import java.io.IOException; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import javax.annotation.Nonnull; +import org.objectweb.asm.Type; + +public abstract class CallSiteUtils { + + private CallSiteUtils() {} + + public static Type classNameToType(@Nonnull final String className) { + return Type.getType(classNameToDescriptor(className)); + } + + public static String classNameToDescriptor(@Nonnull final Class clazz) { + return classNameToDescriptor(clazz.getName()); + } + + public static String classNameToDescriptor(@Nonnull final String className) { + return "L" + className.replaceAll("\\.", "/") + ";"; + } + + public static String capitalize(final String str) { + if (str == null || str.isEmpty()) { + return str; + } + if (str.length() == 1) { + return str.toUpperCase(); + } + return str.substring(0, 1).toUpperCase() + str.substring(1); + } + + public static void createNewFile(@Nonnull final File file) { + final File folder = file.getParentFile(); + if (!folder.exists() && !folder.mkdirs()) { + throw new RuntimeException("Cannot create folder: " + folder); + } + deleteFile(file); + try { + if (!file.createNewFile()) { + throw new RuntimeException("Cannot create file: " + file); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void deleteFile(@Nonnull final File file) { + if (file.exists() && !file.delete()) { + throw new RuntimeException("Cannot delete file: " + file); + } + } + + public static String repeat(@Nonnull final String value, int count) { + if (count < 0) { + throw new IllegalArgumentException("count is negative: " + count); + } + if (count == 1) { + return value; + } + return IntStream.range(0, count).mapToObj(i -> value).collect(Collectors.joining()); + } + + public static String repeat(final char value, int count) { + return repeat(Character.toString(value), count); + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/ErrorCode.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/ErrorCode.java new file mode 100644 index 00000000000..8e16582256a --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/ErrorCode.java @@ -0,0 +1,403 @@ +package datadog.trace.plugin.csi.util; + +import java.util.Set; +import java.util.function.Function; +import org.objectweb.asm.Type; + +public enum ErrorCode implements Function { + + // TypeResolver + + UNRESOLVED_TYPE { + @Override + public String apply(final Object[] objects) { + final Type type = (Type) objects[0]; + return String.format("Cannot resolve type '%s'", type.getClassName()); + } + }, + + UNRESOLVED_METHOD { + @Override + public String apply(final Object[] objects) { + final MethodType method = (MethodType) objects[0]; + return String.format( + "Cannot resolve method '%s' in type '%s' with descriptor '%s'", + method.getMethodName(), method.getOwner(), method.getMethodType().getDescriptor()); + } + }, + + // Specification + + CALL_SITE_SHOULD_HAVE_ADVICE_METHODS { + @Override + public String apply(final Object[] objects) { + return "@CallSite annotated class should contain advice methods (@Before, @Around or @After)"; + } + }, + + CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE { + @Override + public String apply(final Object[] objects) { + final Class spiClass = (Class) objects[0]; + return String.format("@CallSite spi class should be an interface, received '%s'", spiClass); + } + }, + + CALL_SITE_SPI_SHOULD_BE_EMPTY { + @Override + public String apply(final Object[] objects) { + final Class spiClass = (Class) objects[0]; + return String.format( + "@CallSite spi class should not define any methods, spi class '%s'", spiClass); + } + }, + + ADVICE_METHOD_NOT_STATIC_AND_PUBLIC { + @Override + public String apply(final Object[] objects) { + return "Advice method should be static and public"; + } + }, + + ADVICE_METHOD_RETURN_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site return '%s' not compatible with pointcut return '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site @This with type '%s' not compatible with pointcut owner '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site @Return with type '%s' not compatible with pointcut owner '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_METHOD_PARAM_ALL_ARGS_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site @AllArguments with type '%s' not compatible with '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_METHOD_PARAM_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + final int index = (int) objects[2]; + return String.format( + "Call site @Argument with type '%s' not compatible with pointcut parameter type '%s', found at index '%s'", + callSite.getClassName(), pointcut.getClassName(), index); + } + }, + + ADVICE_PARAMETER_NOT_ANNOTATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format("Call site parameter not annotated, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_THIS_DUPLICATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @This is duplicated, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @This should be the first, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_THIS_ON_STATIC_METHOD { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter can't be annotated with @This for static pointcut methods, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_THIS_ON_INVOKE_DYNAMIC { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter can't be annotated with @This for invoke dynamic instructions, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_RETURN_DUPLICATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @Return duplicated, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_ALL_ARGS_DUPLICATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @AllArguments duplicated, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_DUPLICATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @InvokeDynamicConstants duplicated, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site @InvokeDynamicConstants with type '%s' not compatible with '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @InvokeDynamicConstants should be the last, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @InvokeDynamicConstants should only be used on @After call sites, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @InvokeDynamicConstants should target only invoke dynamic instructions, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_ALL_ARGS_MIXED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @AllArguments mixed with @Argument, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @Argument out of bounds for pointcut, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_ON_INVOKE_DYNAMIC { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameters can't be annotated with @Argument for invoke dynamic instructions, found at index '%s'", + index); + } + }, + + ADVICE_POINT_CUT_PARAMETERS_NOT_CONSUMED { + @SuppressWarnings("unchecked") + @Override + public String apply(final Object[] objects) { + final Set index = (Set) objects[0]; + return String.format("Call site not consuming all '%s' required parameters", index); + } + }, + + ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @Return should be last, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_RETURN_NON_AFTER_ADVICE { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @Return should only be used on @After call sites, found at index '%s'", + index); + } + }, + + ADVICE_POINTCUT_CONSTRUCTOR_NOT_VOID { + @Override + public String apply(final Object[] objects) { + final MethodType pointcut = (MethodType) objects[0]; + return String.format( + "Pointcut return type should be void for constructors, found '%s'", + pointcut.getMethodType().getReturnType().getClassName()); + } + }, + + ADVICE_BEFORE_SHOULD_RETURN_VOID { + @Override + public String apply(final Object[] objects) { + final MethodType advice = (MethodType) objects[0]; + return String.format( + "Before advice should return void, received '%s'", + advice.getMethodType().getReturnType().getClassName()); + } + }, + + ADVICE_BEFORE_SHOULD_NOT_CONTAIN_RETURN { + @Override + public String apply(final Object[] objects) { + return "Before advice should not contain @Return annotated parameters"; + } + }, + + ADVICE_BEFORE_CTOR_SHOULD_NOT_CONTAIN_THIS { + @Override + public String apply(final Object[] objects) { + return "Before advice should not contain @This annotated parameters in constructors"; + } + }, + + ADVICE_AROUND_SHOULD_NOT_RETURN_VOID { + @Override + public String apply(final Object[] objects) { + return "Around advice should not return void"; + } + }, + + ADVICE_AROUND_POINTCUT_CTOR { + @Override + public String apply(final Object[] objects) { + return "Around advice not yet supported for constructors"; + } + }, + + ADVICE_AROUND_SHOULD_NOT_CONTAIN_RETURN { + @Override + public String apply(final Object[] objects) { + return "Around advice should not contain @Return annotated parameters"; + } + }, + + ADVICE_AFTER_SHOULD_NOT_RETURN_VOID { + @Override + public String apply(final Object[] objects) { + return "After advice should not return void"; + } + }, + + ADVICE_AFTER_SHOULD_HAVE_THIS { + @Override + public String apply(final Object[] objects) { + return "After advice first parameter should be annotated with @This or @AllArguments(includeThis = true)"; + } + }, + + ADVICE_AFTER_SHOULD_HAVE_RETURN { + @Override + public String apply(final Object[] objects) { + return "After advice last parameter should be annotated with @Return for non constructors"; + } + }, + + ADVICE_AFTER_CONSTRUCTOR_SHOULD_NOT_HAVE_RETURN { + @Override + public String apply(final Object[] objects) { + return "After advice should not be annotated with @Return for constructors"; + } + }, + + POINTCUT_SIGNATURE_INVALID { + @Override + public String apply(final Object[] objects) { + final String signature = (String) objects[0]; + final String pattern = (String) objects[1]; + return String.format( + "Pointcut signature '%s' does not match the regular expression '%s'", signature, pattern); + } + }, + + POINTCUT_SIGNATURE_INVALID_TYPE { + @Override + public String apply(final Object[] objects) { + final String signature = (String) objects[0]; + final String type = (String) objects[1]; + return String.format("Pointcut signature '%s' contains in valid type '%s'", signature, type); + } + }, + + // Others + + UNCAUGHT_ERROR { + @Override + public String apply(final Object[] objects) { + return "Uncaught error"; + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/MethodType.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/MethodType.java new file mode 100644 index 00000000000..ea1dddf5eb8 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/MethodType.java @@ -0,0 +1,83 @@ +package datadog.trace.plugin.csi.util; + +import static datadog.trace.plugin.csi.util.CallSiteConstants.CONSTRUCTOR_METHOD; + +import java.util.Arrays; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import org.objectweb.asm.Type; + +/** Description of a method including its declaring type, name and descriptor. */ +public class MethodType { + + private final Type owner; + private final String methodName; + private final Type methodType; + private final boolean constructor; + + public MethodType( + @Nonnull final Type owner, @Nonnull final String methodName, @Nonnull final Type methodType) { + if (owner.getSort() == Type.METHOD) { + throw new IllegalArgumentException("Owner should not be a method " + owner); + } + if (methodType.getSort() != Type.METHOD) { + throw new IllegalArgumentException("Type should be a method " + methodType); + } + this.owner = owner; + this.methodName = methodName; + this.methodType = methodType; + constructor = CONSTRUCTOR_METHOD.equals(methodName); + } + + public Type getOwner() { + return owner; + } + + public String getMethodName() { + return methodName; + } + + public Type getMethodType() { + return methodType; + } + + public boolean isConstructor() { + return constructor; + } + + public boolean isVoidReturn() { + return Type.VOID_TYPE.equals(methodType.getReturnType()); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MethodType that = (MethodType) o; + return Objects.equals(owner, that.owner) + && Objects.equals(methodName, that.methodName) + && Objects.equals(methodType, that.methodType); + } + + @Override + public int hashCode() { + return Objects.hash(owner, methodName, methodType); + } + + @Override + public String toString() { + return String.format( + "%s %s.%s(%s)", + methodType.getReturnType().getClassName(), + owner.getClassName(), + methodName, + Arrays.stream(methodType.getArgumentTypes()) + .map(Type::getClassName) + .collect(Collectors.joining(", "))); + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/resources/csi/advice.ftl b/buildSrc/call-site-instrumentation-plugin/src/main/resources/csi/advice.ftl new file mode 100644 index 00000000000..eead3cc9639 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/resources/csi/advice.ftl @@ -0,0 +1,63 @@ +<#if packageName??>package ${packageName}; +<#assign customSpiPackage = spiPackageName?? && spiPackageName != packageName> +<#else> +<#assign customSpiPackage = !spiPackageName??> + +<#assign customSpiClass = (spiPackageName != 'datadog.trace.agent.tooling.csi' && spiClassName != 'CallSiteAdvice')> +<#assign hasHelpers = helperClassNames?size != 0> +import datadog.trace.agent.tooling.csi.CallSiteAdvice; +import datadog.trace.agent.tooling.csi.Pointcut; +import datadog.trace.agent.tooling.csi.<#if dynamicInvoke>InvokeDynamicAdvice<#else>InvokeAdvice; +import net.bytebuddy.jar.asm.Opcodes; +<#if dynamicInvoke>import net.bytebuddy.jar.asm.Handle; +import com.google.auto.service.AutoService; +<#if customSpiPackage>import ${spiPackageName}.${spiClassName}; + +@AutoService(${spiClassName}.class) +public final class ${className} implements CallSiteAdvice, Pointcut, <#if dynamicInvoke>InvokeDynamicAdvice<#else>InvokeAdvice<#if computeMaxStack>, CallSiteAdvice.HasFlags<#if hasHelpers>, CallSiteAdvice.HasHelpers<#if customSpiClass>, ${spiClassName} { + + @Override + public Pointcut pointcut() { + return this; + } + +<#if dynamicInvoke> + public void apply(final MethodHandler handler, final String name, final String descriptor, final Handle bootstrapMethodHandle, final Object... bootstrapMethodArguments) { +<#else> + public void apply(final MethodHandler handler, final int opcode, final String owner, final String name, final String descriptor, final boolean isInterface) { + +${applyBody} + } + + @Override + public String type() { + return "${type}"; + } + + @Override + public String method() { + return "${method}"; + } + + @Override + public String descriptor() { + return "${methodDescriptor}"; + } + +<#if computeMaxStack> + @Override + public int flags() { + return COMPUTE_MAX_STACK; + } + + +<#if hasHelpers> + @Override + public String[] helperClassNames() { + return new String[] { +<#list helperClassNames as helper> "${helper}"<#if helper?has_next>, + + }; + } + +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/resources/csi/console.ftl b/buildSrc/call-site-instrumentation-plugin/src/main/resources/csi/console.ftl new file mode 100644 index 00000000000..95de35d904d --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/main/resources/csi/console.ftl @@ -0,0 +1,15 @@ +Call Site Instrumentation plugin results: +<#list results as result> +[<#if result.success>✓<#else>⨉] @CallSite ${result.specification.clazz.className} +<#list toList(result.errors) as error> + [${error.errorCode}] ${error.message} +<#if error.cause??>${error.causeString} + +<#list toList(result.advices) as adviceResult> + [<#if adviceResult.success>✓<#else>⨉] ${adviceResult.specification.advice.methodName} (${adviceResult.specification.signature}) + <#list toList(adviceResult.errors) as error> + [${error.errorCode}] ${error.message} +<#if error.cause??>${error.causeString} + + + diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/AdviceSpecificationTest.groovy b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/AdviceSpecificationTest.groovy new file mode 100644 index 00000000000..3b634a3c014 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/AdviceSpecificationTest.groovy @@ -0,0 +1,542 @@ +package datadog.trace.plugin.csi.impl + +import datadog.trace.agent.tooling.csi.CallSite +import datadog.trace.plugin.csi.HasErrors.Failure +import datadog.trace.plugin.csi.util.ErrorCode +import org.objectweb.asm.Type + +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ThisSpecification as This +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ReturnSpecification as Return +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ArgumentSpecification as Arg +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AllArgsSpecification as AllArgs +import datadog.trace.plugin.csi.impl.CallSiteSpecification.InvokeDynamicConstantsSpecification as DynConsts +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ParameterSpecification +import spock.lang.Requires + +import javax.servlet.ServletRequest +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType +import java.security.MessageDigest + +class AdviceSpecificationTest extends BaseCsiPluginTest { + + @CallSite + class EmptyAdvice {} + + def 'test class generator error, call site without advices'() { + setup: + final context = mockValidationContext() + final spec = buildClassSpecification(EmptyAdvice) + + when: + spec.validate(context) + + then: + 1 * context.addError(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS, _) + } + + @CallSite + class NonPublicStaticMethodAdvice { + @CallSite.Before("void java.lang.Runnable.run()") + private void advice(@CallSite.This final Runnable run) {} + } + + def 'test class generator error, non public static method'() { + setup: + final context = mockValidationContext() + final spec = buildClassSpecification(NonPublicStaticMethodAdvice) + + when: + spec.advices.each { it.validate(context) } + + then: + 1 * context.addError(ErrorCode.ADVICE_METHOD_NOT_STATIC_AND_PUBLIC, _) + } + + class BeforeStringConcat { + static void concat(final String self, final String value) {} + } + + def 'advice class should be on the classpath'(final Type type, final int errors) { + setup: + final context = mockValidationContext() + final spec = before { + advice { + method(BeforeStringConcat.getDeclaredMethod('concat', String, String)) + owner(type) // override owner + } + parameters(new This(), new Arg()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError({ Failure failure -> failure.errorCode == ErrorCode.UNRESOLVED_TYPE }) + 0 * context.addError(*_) + + where: + type || errors + Type.getType('Lfoo/bar/FooBar;') || 1 + Type.getType(BeforeStringConcat) || 0 + } + + def 'before advice should return void'(final Class returnType, final int errors) { + setup: + final context = mockValidationContext() + final spec = before { + advice { + owner(BeforeStringConcat) + method('concat') + descriptor(returnType, String, String) // change return + } + parameters(new This(), new Arg()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError(ErrorCode.ADVICE_BEFORE_SHOULD_RETURN_VOID, _) + 0 * context.addError(*_) + + + where: + returnType || errors + String || 1 + void.class || 0 + } + + class AroundStringConcat { + static String concat(final String self, final String value) { + return self.concat(value) + } + } + + def 'around advice should return type compatible with pointcut'(final Class returnType, final int errors) { + setup: + final context = mockValidationContext() + final spec = around { + advice { + owner(AroundStringConcat) + method('concat') + descriptor(returnType, String, String) // change return + } + parameters(new This(), new Arg()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError(ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE, _) + 0 * context.addError(*_) + + where: + returnType || errors + MessageDigest || 1 + Object || 0 + String || 0 + } + + class AfterStringConcat { + static String concat(final String self, final String value, final String result) { + return result + } + } + + def 'after advice should return type compatible with pointcut'(final Class returnType, final int errors) { + setup: + final context = mockValidationContext() + final spec = after { + advice { + owner(AfterStringConcat) + method('concat') + descriptor(returnType, String, String, String) + // change return + } + parameters(new This(), new Arg(), new Return()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError(ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE, _) + 0 * context.addError(*_) + + where: + returnType || errors + MessageDigest || 1 + Object || 0 + String || 0 + } + + def 'this parameter should always be the first'(final List params, final int errors) { + setup: + final context = mockValidationContext() + final spec = around { + advice { + method(AroundStringConcat.getDeclaredMethod('concat', String, String)) + } + parameters(params as ParameterSpecification[]) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError(ErrorCode.ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST, _) + 0 * context.addError(*_) + + where: + params || errors + [new This(), new Arg()] || 0 + [new Arg(), new This()] || 1 + } + + + def 'this parameter should be compatible with pointcut'(final Class type, final int errors) { + setup: + final context = mockValidationContext() + final spec = around { + advice { + owner(AroundStringConcat) + method('concat') + descriptor(String, type, String) + } + parameters(new This(), new Arg()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError(ErrorCode.ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE, _) + // advice returns String so other return types won't be able to find the method + if (type != String) { + 1 * context.addError({ Failure failure -> failure.errorCode == ErrorCode.UNRESOLVED_METHOD }) + } + 0 * context.addError(*_) + + where: + type || errors + MessageDigest || 1 + Object || 0 + String || 0 + } + + def 'return parameter should always be the last'(final List params, final int errors) { + setup: + final context = mockValidationContext() + final spec = after { + advice { + method(AfterStringConcat.getDeclaredMethod('concat', String, String, String)) + } + parameters(params as ParameterSpecification[]) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST, _) + // other errors are ignored + + where: + params || errors + [new This(), new Arg(), new Return()] || 0 + [new This(), new Return(), new Arg()] || 1 + } + + + def 'return parameter should be compatible with pointcut'(final Class returnType, final int errors) { + setup: + final context = mockValidationContext() + final spec = after { + advice { + owner(AfterStringConcat) + method('concat') + descriptor(String, String, String, returnType) + } + parameters(new This(), new Arg(), new Return()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError(ErrorCode.ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE, _) + // advice returns String so other return types won't be able to find the method + if (returnType != String) { + 1 * context.addError({ Failure failure -> failure.errorCode == ErrorCode.UNRESOLVED_METHOD }) + } + 0 * context.addError(*_) + + where: + returnType || errors + MessageDigest || 1 + String || 0 + Object || 0 + } + + + def 'argument parameter should be compatible with pointcut'(final Class parameterType, final int errors) { + setup: + final context = mockValidationContext() + final spec = after { + advice { + owner(AfterStringConcat) + method('concat') + descriptor(String, String, parameterType, String) + } + parameters(new This(), new Arg(), new Return()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + errors * context.addError(ErrorCode.ADVICE_METHOD_PARAM_NOT_COMPATIBLE, _) + // advice parameter is a String so with other types won't be able to find the method + if (parameterType != String) { + 1 * context.addError({ Failure failure -> failure.errorCode == ErrorCode.UNRESOLVED_METHOD }) + } + 0 * context.addError(*_) + + where: + parameterType || errors + MessageDigest || 1 + String || 0 + Object || 0 + } + + class BadAfterStringConcat { + static String concat(final String param1, final String param2) { + return param2 + } + } + + def 'after advice requires @This and @Return parameters'(final List params, final ErrorCode error) { + setup: + final context = mockValidationContext() + final spec = after { + advice { + method(BadAfterStringConcat.getDeclaredMethod('concat', String, String)) + } + parameters(params as ParameterSpecification[]) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + 1 * context.addError(error, _) + 0 * context.addError(*_) + + where: + params || error + [new Arg(), new Return()] || ErrorCode.ADVICE_AFTER_SHOULD_HAVE_THIS + [new This(), new Arg()] || ErrorCode.ADVICE_AFTER_SHOULD_HAVE_RETURN + } + + class BadAllArgsAfterStringConcat { + static String concat(final Object[] param1, final String param2, final String param3) { + return param3 + } + } + + def 'should not mix @AllArguments and @Argument'() { + setup: + final context = mockValidationContext() + final spec = after { + advice { + method(BadAllArgsAfterStringConcat.getDeclaredMethod('concat', Object[], String, String)) + } + parameters(new AllArgs(includeThis: true), new Arg(), new Return()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + 1 * context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED, _) + 1 * context.addError(ErrorCode.ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS, _) // all args consumes all arguments + 0 * context.addError(*_) + } + + static class TestInheritedMethod { + static String after(final ServletRequest request, final String parameter, final String value) { + return value + } + } + + def 'test inherited methods'() { + setup: + final context = mockValidationContext() + final spec = after { + advice { + method(TestInheritedMethod.getDeclaredMethod('after', ServletRequest, String, String)) + } + parameters(new This(), new Arg(), new Return()) + signature('java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)') + } + + when: + spec.validate(context) + + then: + 0 * context.addError(*_) + } + + static class TestInvokeDynamicConstants { + static Object after(final Object[] parameter, final Object result, final Object[] constants) { + return result + } + } + + @Requires({ + jvm.java9Compatible + }) + def 'invoke dynamic constants'() { + setup: + final context = mockValidationContext() + final spec = after { + advice { + method(TestInvokeDynamicConstants.getDeclaredMethod('after', Object[], Object, Object[])) + } + parameters(new AllArgs(), new Return(), new DynConsts()) + signature('java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])') + invokeDynamic(true) + } + + when: + spec.validate(context) + + then: + 0 * context.addError(*_) + } + + @Requires({ + jvm.java9Compatible + }) + def 'invoke dynamic constants should be last'(final List params, final ErrorCode error) { + setup: + final context = mockValidationContext() + final spec = after { + advice { + method(TestInvokeDynamicConstants.getDeclaredMethod('after', Object[], Object, Object[])) + } + parameters(params as ParameterSpecification[]) + signature('java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])') + invokeDynamic(true) + } + + when: + spec.validate(context) + + then: + if (error != null) { + 1 * context.addError(error, _) + } + 0 * context.addError(*_) + + where: + params || error + [new AllArgs(), new Return(), new DynConsts()] || null + [new AllArgs(), new DynConsts(), new Return()] || ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST + } + + static class TestInvokeDynamicConstantsNonInvokeDynamic { + static Object after(final Object self, final Object[] parameter, final Object value, final Object[] constants) { + return value + } + } + + @Requires({ + jvm.java9Compatible + }) + def 'invoke dynamic constants on non invoke dynamic pointcut'() { + setup: + final context = mockValidationContext() + final spec = after { + advice { + method(TestInvokeDynamicConstantsNonInvokeDynamic.getDeclaredMethod('after', Object, Object[], Object, Object[])) + } + parameters(new This(), new AllArgs(), new DynConsts(), new Return()) + signature('java.lang.String java.lang.String.concat(java.lang.String)') + } + + when: + spec.validate(context) + + then: + 1 * context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC, _) + } + + static class TestInvokeDynamicConstantsBefore { + static void before(final Object[] parameter, final Object[] constants) { + } + } + + @Requires({ + jvm.java9Compatible + }) + def 'invoke dynamic constants on non @After advice'() { + setup: + final context = mockValidationContext() + final spec = before { + advice { + method(TestInvokeDynamicConstantsBefore.getDeclaredMethod('before', Object[], Object[])) + } + parameters(new AllArgs(), new DynConsts()) + signature('java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])') + invokeDynamic(true) + } + + when: + spec.validate(context) + + then: + 1 * context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE, _) + } + + static class TestInvokeDynamicConstantsAround { + static java.lang.invoke.CallSite around(final MethodHandles.Lookup lookup, final String name, final MethodType concatType, final String recipe, final Object... constants) { + return null + } + } + + @Requires({ + jvm.java9Compatible + }) + def 'invoke dynamic on @Around advice'() { + setup: + final context = mockValidationContext() + final spec = around { + advice { + method(TestInvokeDynamicConstantsAround.getDeclaredMethod('around', MethodHandles.Lookup, String, MethodType, String, Object[])) + } + parameters(new Arg(), new Arg(), new Arg(), new Arg(), new Arg()) + signature('java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])') + invokeDynamic(true) + } + + when: + spec.validate(context) + + then: + 0 * context.addError(_, _) + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/AsmSpecificationBuilderTest.groovy b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/AsmSpecificationBuilderTest.groovy new file mode 100644 index 00000000000..84f4aa7a7d5 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/AsmSpecificationBuilderTest.groovy @@ -0,0 +1,452 @@ +package datadog.trace.plugin.csi.impl + +import datadog.trace.agent.tooling.csi.CallSite +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AdviceSpecification +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AfterSpecification +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AroundSpecification +import datadog.trace.plugin.csi.impl.CallSiteSpecification.BeforeSpecification +import org.objectweb.asm.Type + +import javax.servlet.ServletRequest +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType +import java.util.stream.Collectors + +final class AsmSpecificationBuilderTest extends BaseCsiPluginTest { + + static class NonCallSite {} + + def 'test specification builder for non call site'() { + setup: + final advice = fetchClass(NonCallSite) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice) + + then: + !result.present + } + + @CallSite(spi = Spi) + static class WithSpiClass { + interface Spi {} + } + + def 'test specification builder with custom spi class'() { + setup: + final advice = fetchClass(WithSpiClass) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.spi == Type.getType(WithSpiClass.Spi) + } + + @CallSite(helpers = [SampleHelper1.class, SampleHelper2.class]) + static class HelpersAdvice { + static class SampleHelper1 {} + static class SampleHelper2 {} + } + + def 'test specification builder with custom helper classes'() { + setup: + final advice = fetchClass(HelpersAdvice) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.helpers.toList().containsAll([ + Type.getType(HelpersAdvice), + Type.getType(HelpersAdvice.SampleHelper1), + Type.getType(HelpersAdvice.SampleHelper2) + ]) + } + + @CallSite + static class BeforeAdvice { + @CallSite.Before('java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)') + static void before(@CallSite.This final String self, @CallSite.Argument final String regexp, @CallSite.Argument final String replacement) { + } + } + + def 'test specification builder for before advice'() { + setup: + final advice = fetchClass(BeforeAdvice) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == BeforeAdvice.name + final beforeSpec = findAdvice(result, 'before') + beforeSpec instanceof BeforeSpecification + beforeSpec.advice.methodType.descriptor == '(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V' + beforeSpec.signature == 'java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)' + beforeSpec.findThis() != null + beforeSpec.findReturn() == null + beforeSpec.findAllArguments() == null + beforeSpec.findInvokeDynamicConstants() == null + final arguments = getArguments(beforeSpec) + arguments == [0, 1] + } + + @CallSite + static class AroundAdvice { + @CallSite.Around('java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)') + static String around(@CallSite.This final String self, @CallSite.Argument final String regexp, @CallSite.Argument final String replacement) { + return self.replaceAll(regexp, replacement) + } + } + + def 'test specification builder for around advice'() { + setup: + final advice = fetchClass(AroundAdvice) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == AroundAdvice.name + final aroundSpec = findAdvice(result, 'around') + aroundSpec instanceof AroundSpecification + aroundSpec.advice.methodType.descriptor == '(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;' + aroundSpec.signature == 'java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)' + aroundSpec.findThis() != null + aroundSpec.findReturn() == null + aroundSpec.findAllArguments() == null + aroundSpec.findInvokeDynamicConstants() == null + final arguments = getArguments(aroundSpec) + arguments == [0, 1] + } + + @CallSite + static class AfterAdvice { + @CallSite.After('java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)') + static String after(@CallSite.This final String self, @CallSite.Argument final String regexp, @CallSite.Argument final String replacement, @CallSite.Return final String result) { + return result + } + } + + def 'test specification builder for after advice'() { + setup: + final advice = fetchClass(AfterAdvice) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == AfterAdvice.name + final afterSpec = findAdvice(result, 'after') + afterSpec instanceof AfterSpecification + afterSpec.advice.methodType.descriptor == '(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;' + afterSpec.signature == 'java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)' + afterSpec.findThis() != null + afterSpec.findReturn() != null + afterSpec.findAllArguments() == null + afterSpec.findInvokeDynamicConstants() == null + final arguments = getArguments(afterSpec) + arguments == [0, 1] + } + + @CallSite + static class AllArgsAdvice { + @CallSite.Around('java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)') + static String allArgs(@CallSite.AllArguments(includeThis = true) final Object[] arguments, @CallSite.Return final String result) { + return result + } + } + + def 'test specification builder for advice with @AllArguments'() { + setup: + final advice = fetchClass(AllArgsAdvice) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == AllArgsAdvice.name + final allArgsSpec = findAdvice(result, 'allArgs') + allArgsSpec instanceof AroundSpecification + allArgsSpec.advice.methodType.descriptor == '([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;' + allArgsSpec.signature == 'java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)' + allArgsSpec.findThis() == null + allArgsSpec.findReturn() != null + final allArguments = allArgsSpec.findAllArguments() + allArguments != null + allArguments.includeThis + allArgsSpec.findInvokeDynamicConstants() == null + final arguments = getArguments(allArgsSpec) + arguments == [] + } + + @CallSite + static class InvokeDynamicBeforeAdvice { + @CallSite.After( + value = 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])', + invokeDynamic = true + ) + static String invokeDynamic(@CallSite.AllArguments final Object[] arguments, @CallSite.Return final String result) { + return result + } + } + + def 'test specification builder for before invoke dynamic'() { + setup: + final advice = fetchClass(InvokeDynamicBeforeAdvice) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == InvokeDynamicBeforeAdvice.name + final invokeDynamicSpec = findAdvice(result, 'invokeDynamic') + invokeDynamicSpec instanceof AfterSpecification + invokeDynamicSpec.advice.methodType.descriptor == '([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;' + invokeDynamicSpec.signature == 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])' + invokeDynamicSpec.findThis() == null + invokeDynamicSpec.findReturn() != null + final allArguments = invokeDynamicSpec.findAllArguments() + allArguments != null + !allArguments.includeThis + invokeDynamicSpec.findInvokeDynamicConstants() == null + final arguments = getArguments(invokeDynamicSpec) + arguments == [] + } + + @CallSite + static class InvokeDynamicAroundAdvice { + @CallSite.Around( + value = 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])', + invokeDynamic = true + ) + static java.lang.invoke.CallSite invokeDynamic(@CallSite.Argument final MethodHandles.Lookup lookup, + @CallSite.Argument final String name, + @CallSite.Argument final MethodType concatType, + @CallSite.Argument final String recipe, + @CallSite.Argument final Object... constants) { + return null + } + } + + def 'test specification builder for around invoke dynamic'() { + setup: + final advice = fetchClass(InvokeDynamicAroundAdvice) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == InvokeDynamicAroundAdvice.name + final invokeDynamicSpec = findAdvice(result, 'invokeDynamic') + invokeDynamicSpec instanceof AroundSpecification + invokeDynamicSpec.advice.methodType.descriptor == '(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;' + invokeDynamicSpec.signature == 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])' + invokeDynamicSpec.findThis() == null + invokeDynamicSpec.findReturn() == null + invokeDynamicSpec.findAllArguments() == null + invokeDynamicSpec.findInvokeDynamicConstants() == null + final arguments = getArguments(invokeDynamicSpec) + arguments == [0, 1, 2, 3, 4] + } + + @CallSite + static class TestInvokeDynamicConstants { + @CallSite.After( + value = 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])', + invokeDynamic = true + ) + static String after(@CallSite.AllArguments final Object[] parameter, + @CallSite.InvokeDynamicConstants final Object[] constants, + @CallSite.Return final String value) { + return value + } + } + + def 'test invoke dynamic constants'() { + setup: + final advice = fetchClass(TestInvokeDynamicConstants) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == TestInvokeDynamicConstants.name + final inheritedSpec = findAdvice(result, 'after') + inheritedSpec instanceof AfterSpecification + inheritedSpec.advice.methodType.descriptor == '([Ljava/lang/Object;[Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;' + inheritedSpec.signature == 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])' + inheritedSpec.findThis() == null + inheritedSpec.findReturn() != null + inheritedSpec.findInvokeDynamicConstants() != null + final arguments = getArguments(inheritedSpec) + arguments == [] + } + + @CallSite + static class TestBeforeArray { + + @CallSite.BeforeArray([ + @CallSite.Before('java.util.Map javax.servlet.ServletRequest.getParameterMap()'), + @CallSite.Before('java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()') + ]) + static void before(@CallSite.This final ServletRequest request) { } + } + + def 'test specification builder for before advice array'() { + setup: + final advice = fetchClass(TestBeforeArray) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == TestBeforeArray.name + final list = result.advices.collect(Collectors.toList()) + list.size() == 2 + list.each { + assert it instanceof BeforeSpecification + assert it.advice.methodType.descriptor == '(Ljavax/servlet/ServletRequest;)V' + assert it.signature in [ + 'java.util.Map javax.servlet.ServletRequest.getParameterMap()', + 'java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()' + ] + assert it.findThis() != null + assert it.findReturn() == null + assert it.findAllArguments() == null + assert it.findInvokeDynamicConstants() == null + final arguments = getArguments(it) + assert arguments == [] + } + } + + @CallSite + static class TestAroundArray { + + @CallSite.AroundArray([ + @CallSite.Around('java.util.Map javax.servlet.ServletRequest.getParameterMap()'), + @CallSite.Around('java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()') + ]) + static Map around(@CallSite.This final ServletRequest request) { + return request.getParameterMap() + } + } + + def 'test specification builder for before advice array'() { + setup: + final advice = fetchClass(TestAroundArray) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == TestAroundArray.name + final list = result.advices.collect(Collectors.toList()) + list.size() == 2 + list.each { + assert it instanceof AroundSpecification + assert it.advice.methodType.descriptor == '(Ljavax/servlet/ServletRequest;)Ljava/util/Map;' + assert it.signature in [ + 'java.util.Map javax.servlet.ServletRequest.getParameterMap()', + 'java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()' + ] + assert it.findThis() != null + assert it.findReturn() == null + assert it.findAllArguments() == null + assert it.findInvokeDynamicConstants() == null + final arguments = getArguments(it) + assert arguments == [] + } + } + + @CallSite + static class TestAfterArray { + + @CallSite.AfterArray([ + @CallSite.After('java.util.Map javax.servlet.ServletRequest.getParameterMap()'), + @CallSite.After('java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()') + ]) + static Map after(@CallSite.This final ServletRequest request, @CallSite.Return final Map parameters) { + return parameters + } + } + + def 'test specification builder for before advice array'() { + setup: + final advice = fetchClass(TestAfterArray) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == TestAfterArray.name + final list = result.advices.collect(Collectors.toList()) + list.size() == 2 + list.each { + assert it instanceof AfterSpecification + assert it.advice.methodType.descriptor == '(Ljavax/servlet/ServletRequest;Ljava/util/Map;)Ljava/util/Map;' + assert it.signature in [ + 'java.util.Map javax.servlet.ServletRequest.getParameterMap()', + 'java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()' + ] + assert it.findThis() != null + assert it.findReturn() != null + assert it.findAllArguments() == null + assert it.findInvokeDynamicConstants() == null + final arguments = getArguments(it) + assert arguments == [] + } + } + + @CallSite + static class TestInheritedMethod { + @CallSite.After('java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)') + static String after(@CallSite.This final ServletRequest request, @CallSite.Argument final String parameter, @CallSite.Return final String value) { + return value + } + } + + def 'test specification builder for inherited methods'() { + setup: + final advice = fetchClass(TestInheritedMethod) + final specificationBuilder = new AsmSpecificationBuilder() + + when: + final result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new) + + then: + result.clazz.className == TestInheritedMethod.name + final inheritedSpec = findAdvice(result, 'after') + inheritedSpec instanceof AfterSpecification + inheritedSpec.advice.methodType.descriptor == '(Ljavax/servlet/ServletRequest;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;' + inheritedSpec.signature == 'java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)' + inheritedSpec.findThis() != null + inheritedSpec.findReturn() != null + inheritedSpec.findAllArguments() == null + inheritedSpec.findInvokeDynamicConstants() == null + final arguments = getArguments(inheritedSpec) + arguments == [0] + } + + protected static List getArguments(final AdviceSpecification advice) { + return advice.arguments.map(it -> it.index).collect(Collectors.toList()) + } + + private static AdviceSpecification findAdvice(final CallSiteSpecification result, final String name) { + return result.advices.filter { it.advice.methodName == name }.findFirst().get() + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/BaseCsiPluginTest.groovy b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/BaseCsiPluginTest.groovy new file mode 100644 index 00000000000..df849c3119b --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/BaseCsiPluginTest.groovy @@ -0,0 +1,194 @@ +package datadog.trace.plugin.csi.impl + +import datadog.trace.plugin.csi.HasErrors +import datadog.trace.plugin.csi.ValidationContext +import datadog.trace.plugin.csi.util.MethodType +import org.objectweb.asm.Type +import spock.lang.Specification + +import java.lang.reflect.Constructor +import java.lang.reflect.Executable +import java.lang.reflect.Method +import java.util.stream.Collectors +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ParameterSpecification +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AdviceSpecification +import datadog.trace.plugin.csi.impl.CallSiteSpecification.BeforeSpecification +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AroundSpecification +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AfterSpecification +import datadog.trace.plugin.csi.impl.CallSiteSpecification.ArgumentSpecification + +import static datadog.trace.plugin.csi.impl.CallSiteFactory.pointcutParser +import static datadog.trace.plugin.csi.impl.CallSiteFactory.specificationBuilder +import static datadog.trace.plugin.csi.impl.CallSiteFactory.typeResolver +import static datadog.trace.plugin.csi.util.CallSiteConstants.TYPE_RESOLVER + +abstract class BaseCsiPluginTest extends Specification { + + protected static void assertNoErrors(final HasErrors hasErrors) { + final errors = hasErrors.errors + .map(error -> "${error.message}: ${error.cause == null ? '-' : error.causeString}") + .collect(Collectors.toList()) + assert errors == [] + } + + protected static File fetchClass(final Class clazz) { + final folder = new File(clazz.getResource('.').toURI()) + final classFile = clazz.getName().replace(clazz.getPackage().getName() + '.', '') + '.class' + return new File(folder, classFile) + } + + protected static CallSiteSpecification buildClassSpecification(final Class clazz) { + final classFile = fetchClass(clazz) + final spec = specificationBuilder().build(classFile).get() + final pointcutParser = pointcutParser() + spec.advices.each { it.parseSignature(pointcutParser) } + return spec + } + + protected ValidationContext mockValidationContext() { + return Mock(ValidationContext) { + mock -> + mock.getContextProperty(TYPE_RESOLVER) >> typeResolver() + } + } + + protected static BeforeSpecification before(@DelegatesTo(strategy = Closure.DELEGATE_ONLY, value = BeforeAdviceSpecificationBuilder) final Closure cl) { + final spec = new BeforeAdviceSpecificationBuilder() + final code = cl.rehydrate(spec, this, this) + code.resolveStrategy = Closure.DELEGATE_ONLY + code() + return spec.build() + } + + protected static AroundSpecification around(@DelegatesTo(strategy = Closure.DELEGATE_ONLY, value = AroundAdviceSpecificationBuilder) final Closure cl) { + final spec = new AroundAdviceSpecificationBuilder() + final code = cl.rehydrate(spec, this, this) + code.resolveStrategy = Closure.DELEGATE_ONLY + code() + return spec.build() + } + + protected static AfterSpecification after(@DelegatesTo(strategy = Closure.DELEGATE_ONLY, value = AfterAdviceSpecificationBuilder) final Closure cl) { + final spec = new AfterAdviceSpecificationBuilder() + final code = cl.rehydrate(spec, this, this) + code.resolveStrategy = Closure.DELEGATE_ONLY + code() + return spec.build() + } + + private static class BeforeAdviceSpecificationBuilder extends AdviceSpecificationBuilder { + @Override + protected AdviceSpecification build(final MethodType advice, + final Map parameters, + final String signature, + final boolean invokeDynamic) { + return new BeforeSpecification(advice, parameters, signature, invokeDynamic) + } + } + + private static class AroundAdviceSpecificationBuilder extends AdviceSpecificationBuilder { + @Override + protected AroundSpecification build(final MethodType advice, + final Map parameters, + final String signature, + final boolean invokeDynamic) { + return new AroundSpecification(advice, parameters, signature, invokeDynamic) + } + } + + private static class AfterAdviceSpecificationBuilder extends AdviceSpecificationBuilder { + @Override + protected AfterSpecification build(final MethodType advice, + final Map parameters, + final String signature, + final boolean invokeDynamic) { + return new AfterSpecification(advice, parameters, signature, invokeDynamic) + } + } + + private abstract static class AdviceSpecificationBuilder { + protected MethodType advice + protected Map parameters + protected String signature + protected boolean invokeDynamic + + void advice(@DelegatesTo(strategy = Closure.DELEGATE_ONLY, value = MethodTypeBuilder) final Closure body) { + final spec = new MethodTypeBuilder() + final code = body.rehydrate(spec, this, this) + code.resolveStrategy = Closure.DELEGATE_ONLY + code() + advice = spec.build() + } + + void parameters(final ParameterSpecification... parameters) { + this.parameters = [:] + parameters.eachWithIndex { entry, int i -> this.parameters.put(i, entry) } + parameters.grep { it instanceof ArgumentSpecification } + .collect { it as ArgumentSpecification} + .eachWithIndex{ spec, int i -> spec.index = i} + } + + void signature(final String signature) { + this.signature = signature + } + + void invokeDynamic(final boolean invokeDynamic) { + this.invokeDynamic = invokeDynamic + } + + E build() { + final result = build(advice, parameters, signature, invokeDynamic) as E + result.parseSignature(pointcutParser()) + return result + } + + + protected abstract AdviceSpecification build(final MethodType advice, + final Map parameters, + final String signature, + final boolean invokeDynamic) + } + + private static class MethodTypeBuilder { + protected Type owner + protected String methodName + protected Type methodType + + void owner(final Type value) { + owner = value + } + + void owner(final Class value) { + owner = Type.getType(value) + } + + void method(final String value) { + methodName = value + } + + void descriptor(final Type value) { + methodType = value + } + + void descriptor(final Class returnType, final Class... args) { + methodType = Type.getMethodType(Type.getType(returnType), args.collect { Type.getType(it) } as Type[]) + } + + void method(final Executable executable) { + owner = Type.getType(executable.declaringClass) + final args = executable.parameterTypes.collect { Type.getType(it) }.toArray(new Type[0]) as Type[] + if (executable instanceof Constructor) { + methodName = '' + methodType = Type.getMethodType(Type.VOID_TYPE, args) + } else { + final method = executable as Method + methodName = method.name + methodType = Type.getMethodType(Type.getType(method.getReturnType()), args) + } + } + + private MethodType build() { + return new MethodType(owner, methodName, methodType) + } + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/CallSiteSpecificationTest.groovy b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/CallSiteSpecificationTest.groovy new file mode 100644 index 00000000000..5ae6d4941e1 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/CallSiteSpecificationTest.groovy @@ -0,0 +1,45 @@ +package datadog.trace.plugin.csi.impl + +import datadog.trace.agent.tooling.csi.CallSiteAdvice +import datadog.trace.plugin.csi.util.ErrorCode +import org.objectweb.asm.Type +import datadog.trace.plugin.csi.impl.CallSiteSpecification.AdviceSpecification + +class CallSiteSpecificationTest extends BaseCsiPluginTest { + + def 'test call site spi should be an interface'() { + setup: + final context = mockValidationContext() + final spec = new CallSiteSpecification(Type.getType(String), [Mock(AdviceSpecification)], Type.getType(String), [] as Set) + + when: + spec.validate(context) + + then: + 1 * context.addError(ErrorCode.CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE, _) + } + + def 'test call site spi should not define any methods'() { + setup: + final context = mockValidationContext() + final spec = new CallSiteSpecification(Type.getType(String), [Mock(AdviceSpecification)], Type.getType(Comparable), [] as Set) + + when: + spec.validate(context) + + then: + 1 * context.addError(ErrorCode.CALL_SITE_SPI_SHOULD_BE_EMPTY, _) + } + + def 'test call site should have advices'() { + setup: + final context = mockValidationContext() + final spec = new CallSiteSpecification(Type.getType(String), [], Type.getType(CallSiteAdvice), [] as Set) + + when: + spec.validate(context) + + then: + 1 * context.addError(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS, _) + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest.groovy b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest.groovy new file mode 100644 index 00000000000..ea4325df2b8 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest.groovy @@ -0,0 +1,374 @@ +package datadog.trace.plugin.csi.impl + +import com.github.javaparser.JavaParser +import com.github.javaparser.ast.body.MethodDeclaration +import com.github.javaparser.ast.body.TypeDeclaration +import datadog.trace.agent.tooling.csi.CallSite +import datadog.trace.plugin.csi.AdviceGenerator.AdviceResult +import datadog.trace.plugin.csi.AdviceGenerator.CallSiteResult +import spock.lang.Requires +import spock.lang.TempDir + +import javax.servlet.ServletRequest +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType +import java.util.stream.Collectors + +import static CallSiteFactory.pointcutParser + +final class FreemarkerAdviceGeneratorTest extends BaseCsiPluginTest { + + @TempDir + File buildDir + + @CallSite + class BeforeAdvice { + @CallSite.Before('java.security.MessageDigest java.security.MessageDigest.getInstance(java.lang.String)') + static void before(@CallSite.Argument final String algorithm) {} + } + + def 'test before advice'() { + setup: + final spec = buildClassSpecification(BeforeAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advice = findAdvice(result, 'before') + assertNoErrors(advice) + final javaFile = new JavaParser().parse(advice.file).getResult().get() + final packageDcl = javaFile.getPackageDeclaration().get() + packageDcl.name.asString() == BeforeAdvice.package.name + final adviceClass = javaFile.getType(0) + adviceClass.name.asString().endsWith(BeforeAdvice.simpleName + 'Before') + final interfaces = adviceClass.asClassOrInterfaceDeclaration().implementedTypes.collect {it.name.asString() } + interfaces == ['CallSiteAdvice', 'Pointcut', 'InvokeAdvice', 'HasFlags', 'HasHelpers'] + final methods = groupMethods(adviceClass) + getStatements(methods['pointcut']) == ['return this;'] + getStatements(methods['type']) == ['return "java/security/MessageDigest";'] + getStatements(methods['method']) == ['return "getInstance";'] + getStatements(methods['descriptor']) == ['return "(Ljava/lang/String;)Ljava/security/MessageDigest;";'] + getStatements(methods['helperClassNames']) == ['return new String[] { "' + BeforeAdvice.name + '" };'] + getStatements(methods['flags']) == ['return COMPUTE_MAX_STACK;'] + getStatements(methods['apply']) == [ + 'handler.dupParameters(descriptor, StackDupMode.COPY);', + 'handler.method(Opcodes.INVOKESTATIC, "datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest$BeforeAdvice", "before", "(Ljava/lang/String;)V", false);', + 'handler.method(opcode, owner, name, descriptor, isInterface);' + ] + } + + @CallSite + class AroundAdvice { + @CallSite.Around('java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)') + static String around(@CallSite.This final String self, @CallSite.Argument final String regexp, @CallSite.Argument final String replacement) { + return self.replaceAll(regexp, replacement); + } + } + + def 'test around advice'() { + setup: + final spec = buildClassSpecification(AroundAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advice = findAdvice(result, 'around') + assertNoErrors(advice) + final javaFile = new JavaParser().parse(advice.file).getResult().get() + final packageDcl = javaFile.getPackageDeclaration().get() + packageDcl.name.asString() == AroundAdvice.package.name + final adviceClass = javaFile.getType(0) + adviceClass.name.asString().endsWith(AroundAdvice.simpleName + 'Around') + final interfaces = adviceClass.asClassOrInterfaceDeclaration().implementedTypes.collect {it.name.asString() } + interfaces == ['CallSiteAdvice', 'Pointcut', 'InvokeAdvice', 'HasHelpers'] + final methods = groupMethods(adviceClass) + getStatements(methods['pointcut']) == ['return this;'] + getStatements(methods['type']) == ['return "java/lang/String";'] + getStatements(methods['method']) == ['return "replaceAll";'] + getStatements(methods['descriptor']) == ['return "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;";'] + getStatements(methods['helperClassNames']) == ['return new String[] { "' + AroundAdvice.name + '" };'] + getStatements(methods['apply']) == [ + 'handler.method(Opcodes.INVOKESTATIC, "datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest$AroundAdvice", "around", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", false);' + ] + } + + @CallSite + class AfterAdvice { + @CallSite.After('void java.net.URL.(java.lang.String)') + static URL after(@CallSite.This final URL url, @CallSite.Argument final String spec) { + return url; + } + } + + def 'test after advice'() { + setup: + final spec = buildClassSpecification(AfterAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advice = findAdvice(result, 'after' ) + assertNoErrors(advice) + final javaFile = new JavaParser().parse(advice.file).getResult().get() + final packageDcl = javaFile.getPackageDeclaration().get() + packageDcl.name.asString() == AfterAdvice.package.name + final adviceClass = javaFile.getType(0) + adviceClass.name.asString().endsWith(AfterAdvice.simpleName + 'After') + final interfaces = adviceClass.asClassOrInterfaceDeclaration().implementedTypes.collect {it.name.asString() } + interfaces == ['CallSiteAdvice', 'Pointcut', 'InvokeAdvice', 'HasFlags', 'HasHelpers'] + final methods = groupMethods(adviceClass) + getStatements(methods['pointcut']) == ['return this;'] + getStatements(methods['type']) == ['return "java/net/URL";'] + getStatements(methods['method']) == ['return "";'] + getStatements(methods['descriptor']) == ['return "(Ljava/lang/String;)V";'] + getStatements(methods['helperClassNames']) == ['return new String[] { "' + AfterAdvice.name + '" };'] + getStatements(methods['flags']) == ['return COMPUTE_MAX_STACK;'] + getStatements(methods['apply']) == [ + 'handler.dupInvoke(owner, descriptor, StackDupMode.COPY);', + 'handler.method(opcode, owner, name, descriptor, isInterface);', + 'handler.method(Opcodes.INVOKESTATIC, "datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest$AfterAdvice", "after", "(Ljava/net/URL;Ljava/lang/String;)Ljava/net/URL;", false);', + 'handler.instruction(Opcodes.POP);' + ] + } + + @CallSite(spi = SampleSpi.class) + class SpiAdvice { + @CallSite.Before('java.security.MessageDigest java.security.MessageDigest.getInstance(java.lang.String)') + static void before(@CallSite.Argument final String algorithm) {} + interface SampleSpi {} + } + + def 'test generator with spi'() { + setup: + final spec = buildClassSpecification(SpiAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advice = findAdvice(result, 'before' ) + assertNoErrors(advice) + final text = advice.file.text + text.contains('@AutoService(FreemarkerAdviceGeneratorTest.SpiAdvice.SampleSpi.class)') + } + + @CallSite + class InvokeDynamicAfterAdvice { + @CallSite.After( + value = 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])', + invokeDynamic = true + ) + static String after(@CallSite.AllArguments final Object[] arguments, @CallSite.Return final String result) { + return result; + } + } + + @Requires({ + jvm.java9Compatible + }) + def 'test invoke dynamic after advice'() { + setup: + final spec = buildClassSpecification(InvokeDynamicAfterAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advice = findAdvice(result, 'after' ) + assertNoErrors(advice) + final javaFile = new JavaParser().parse(advice.file).getResult().get() + final packageDcl = javaFile.getPackageDeclaration().get() + packageDcl.name.asString() == InvokeDynamicAfterAdvice.package.name + final adviceClass = javaFile.getType(0) + adviceClass.name.asString().endsWith(InvokeDynamicAfterAdvice.simpleName + 'After') + final interfaces = adviceClass.asClassOrInterfaceDeclaration().implementedTypes.collect {it.name.asString() } + interfaces == ['CallSiteAdvice', 'Pointcut', 'InvokeDynamicAdvice', 'HasFlags', 'HasHelpers'] + final methods = groupMethods(adviceClass) + getStatements(methods['pointcut']) == ['return this;'] + getStatements(methods['type']) == ['return "java/lang/invoke/StringConcatFactory";'] + getStatements(methods['method']) == ['return "makeConcatWithConstants";'] + getStatements(methods['descriptor']) == ['return "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;";'] + getStatements(methods['helperClassNames']) == ['return new String[] { "' + InvokeDynamicAfterAdvice.name + '" };'] + getStatements(methods['flags']) == ['return COMPUTE_MAX_STACK;'] + getStatements(methods['apply']) == [ + 'handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY);', + 'handler.invokeDynamic(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);', + 'handler.method(Opcodes.INVOKESTATIC, "datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest$InvokeDynamicAfterAdvice", "after", "([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;", false);' + ] + } + + @CallSite + class InvokeDynamicAroundAdvice { + @CallSite.Around( + value = 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])', + invokeDynamic = true + ) + static java.lang.invoke.CallSite around(@CallSite.Argument final MethodHandles.Lookup lookup, + @CallSite.Argument final String name, + @CallSite.Argument final MethodType concatType, + @CallSite.Argument final String recipe, + @CallSite.Argument final Object... constants) { + return null; + } + } + + @Requires({ + jvm.java9Compatible + }) + def 'test invoke dynamic around advice'() { + setup: + final spec = buildClassSpecification(InvokeDynamicAroundAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advice = findAdvice(result, 'around' ) + assertNoErrors(advice) + final javaFile = new JavaParser().parse(advice.file).getResult().get() + final packageDcl = javaFile.getPackageDeclaration().get() + packageDcl.name.asString() == InvokeDynamicAroundAdvice.package.name + final adviceClass = javaFile.getType(0) + adviceClass.name.asString().endsWith(InvokeDynamicAroundAdvice.simpleName + 'Around') + final interfaces = adviceClass.asClassOrInterfaceDeclaration().implementedTypes.collect {it.name.asString() } + interfaces == ['CallSiteAdvice', 'Pointcut', 'InvokeDynamicAdvice', 'HasHelpers'] + final methods = groupMethods(adviceClass) + getStatements(methods['pointcut']) == ['return this;'] + getStatements(methods['type']) == ['return "java/lang/invoke/StringConcatFactory";'] + getStatements(methods['method']) == ['return "makeConcatWithConstants";'] + getStatements(methods['descriptor']) == ['return "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;";'] + getStatements(methods['helperClassNames']) == ['return new String[] { "' + InvokeDynamicAroundAdvice.name + '" };'] + getStatements(methods['apply']) == [ + 'handler.invokeDynamic(name, descriptor, new Handle(Opcodes.H_INVOKESTATIC, "datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest$InvokeDynamicAroundAdvice", "around", "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;", false), bootstrapMethodArguments);', + ] + } + + @CallSite + class InvokeDynamicWithConstantsAdvice { + @CallSite.After( + value = 'java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])', + invokeDynamic = true + ) + static String after(@CallSite.AllArguments final Object[] arguments, + @CallSite.Return final String result, + @CallSite.InvokeDynamicConstants final Object[] constants) { + return result; + } + } + + @Requires({ + jvm.java9Compatible + }) + def 'test invoke dynamic with constants advice'() { + setup: + final spec = buildClassSpecification(InvokeDynamicWithConstantsAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advice = findAdvice(result, 'after' ) + assertNoErrors(advice) + final javaFile = new JavaParser().parse(advice.file).getResult().get() + final packageDcl = javaFile.getPackageDeclaration().get() + packageDcl.name.asString() == InvokeDynamicWithConstantsAdvice.package.name + final adviceClass = javaFile.getType(0) + adviceClass.name.asString().endsWith(InvokeDynamicWithConstantsAdvice.simpleName + 'After') + final interfaces = adviceClass.asClassOrInterfaceDeclaration().implementedTypes.collect {it.name.asString() } + interfaces == ['CallSiteAdvice', 'Pointcut', 'InvokeDynamicAdvice', 'HasFlags', 'HasHelpers'] + final methods = groupMethods(adviceClass) + getStatements(methods['pointcut']) == ['return this;'] + getStatements(methods['type']) == ['return "java/lang/invoke/StringConcatFactory";'] + getStatements(methods['method']) == ['return "makeConcatWithConstants";'] + getStatements(methods['descriptor']) == ['return "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;";'] + getStatements(methods['helperClassNames']) == ['return new String[] { "' + InvokeDynamicWithConstantsAdvice.name + '" };'] + getStatements(methods['flags']) == ['return COMPUTE_MAX_STACK;'] + getStatements(methods['apply']) == [ + 'handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY);', + 'handler.invokeDynamic(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);', + 'handler.loadConstantArray(bootstrapMethodArguments);', + 'handler.method(Opcodes.INVOKESTATIC, "datadog/trace/plugin/csi/impl/FreemarkerAdviceGeneratorTest$InvokeDynamicWithConstantsAdvice", "after", "([Ljava/lang/Object;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;", false);' + ] + } + + @CallSite + class SameMethodNameAdvice { + @CallSite.Before('java.security.MessageDigest java.security.MessageDigest.getInstance(java.lang.String)') + static void before(@CallSite.Argument final String algorithm) {} + @CallSite.Before('java.security.MessageDigest java.security.MessageDigest.getInstance(java.lang.String)') + static void before() {} + } + + def 'test multiple methods with the same name advice'() { + setup: + final spec = buildClassSpecification(SameMethodNameAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advices = result.advices.map { it.file.name }.collect(Collectors.toList()) + advices.containsAll(['FreemarkerAdviceGeneratorTest$SameMethodNameAdviceBefore0.java', 'FreemarkerAdviceGeneratorTest$SameMethodNameAdviceBefore1.java']) + } + + @CallSite + class ArrayAdvice { + @CallSite.AfterArray([ + @CallSite.After('java.util.Map javax.servlet.ServletRequest.getParameterMap()'), + @CallSite.After('java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()') + ]) + static Map after(@CallSite.This final ServletRequest request, @CallSite.Return final Map parameters) { + return parameters + } + } + + def 'test array advice'() { + setup: + final spec = buildClassSpecification(ArrayAdvice) + final generator = buildFreemarkerAdviceGenerator(buildDir) + + when: + final result = generator.generate(spec) + + then: + assertNoErrors(result) + final advices = result.advices.map { it.file.name }.collect(Collectors.toList()) + advices.containsAll(['FreemarkerAdviceGeneratorTest$ArrayAdviceAfter0.java', 'FreemarkerAdviceGeneratorTest$ArrayAdviceAfter1.java']) + } + + private static List getStatements(final MethodDeclaration method) { + return method.body.get().statements.collect { it.toString() } + } + + private static FreemarkerAdviceGenerator buildFreemarkerAdviceGenerator(final File targetFolder) { + return new FreemarkerAdviceGenerator(targetFolder, pointcutParser()) + } + + private static Map groupMethods(final TypeDeclaration classNode) { + return classNode.methods.groupBy { it.name.asString() } + .collectEntries { key, value -> [key, value.get(0)] } + } + + private static AdviceResult findAdvice(final CallSiteResult result, final String name) { + return result.advices.filter { it.specification.advice.methodName == name }.findFirst().get() + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParserTest.groovy b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParserTest.groovy new file mode 100644 index 00000000000..e7fdb727b38 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParserTest.groovy @@ -0,0 +1,136 @@ +package datadog.trace.plugin.csi.impl + +import spock.lang.Specification + +final class RegexpAdvicePointcutParserTest extends Specification { + + def 'resolve constructor'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("void datadog.trace.plugin.csi.samples.SignatureParserExample.()") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == '' + signature.methodType.descriptor == '()V' + } + + def 'resolve constructor with args'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("void datadog.trace.plugin.csi.samples.SignatureParserExample.(java.lang.String)") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == '' + signature.methodType.descriptor == '(Ljava/lang/String;)V' + } + + def 'resolve without args'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.noParams()") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == 'noParams' + signature.methodType.descriptor == '()Ljava/lang/String;' + } + + def 'resolve one param'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.oneParam(java.util.Map)") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == 'oneParam' + signature.methodType.descriptor == '(Ljava/util/Map;)Ljava/lang/String;' + } + + def 'resolve multiple params'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.multipleParams(java.lang.String, int, java.util.List)") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == 'multipleParams' + signature.methodType.descriptor == '(Ljava/lang/String;ILjava/util/List;)Ljava/lang/String;' + } + + def 'resolve varargs'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.varargs(java.lang.String[])") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == 'varargs' + signature.methodType.descriptor == '([Ljava/lang/String;)Ljava/lang/String;' + } + + def 'resolve primitive'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("int datadog.trace.plugin.csi.samples.SignatureParserExample.primitive()") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == 'primitive' + signature.methodType.descriptor == '()I' + } + + def 'resolve primitive array type'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("byte[] datadog.trace.plugin.csi.samples.SignatureParserExample.primitiveArray()") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == 'primitiveArray' + signature.methodType.descriptor == '()[B' + } + + def 'resolve object array type'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("java.lang.Object[] datadog.trace.plugin.csi.samples.SignatureParserExample.objectArray()") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == 'objectArray' + signature.methodType.descriptor == '()[Ljava/lang/Object;' + } + + def 'resolve multi dimensional object array type'() { + setup: + final pointcutParser = new RegexpAdvicePointcutParser() + + when: + final signature = pointcutParser.parse("java.lang.Object[][][] datadog.trace.plugin.csi.samples.SignatureParserExample.objectArray()") + + then: + signature.owner.className == 'datadog.trace.plugin.csi.samples.SignatureParserExample' + signature.methodName == 'objectArray' + signature.methodType.descriptor == '()[[[Ljava/lang/Object;' + } +} diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/TypeResolverPoolTest.groovy b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/TypeResolverPoolTest.groovy new file mode 100644 index 00000000000..ffeeb6b7f40 --- /dev/null +++ b/buildSrc/call-site-instrumentation-plugin/src/test/groovy/datadog/trace/plugin/csi/impl/TypeResolverPoolTest.groovy @@ -0,0 +1,109 @@ +package datadog.trace.plugin.csi.impl + +import datadog.trace.plugin.csi.util.MethodType +import org.objectweb.asm.Type +import spock.lang.Specification + +import javax.servlet.ServletRequest +import javax.servlet.http.HttpServletRequest + +class TypeResolverPoolTest extends Specification { + + def 'test resolve primitive'() { + setup: + final resolver = new TypeResolverPool() + + when: + final result = resolver.resolveType(Type.INT_TYPE) + + then: + result == int.class + } + + def 'test resolve primitive array'() { + setup: + final resolver = new TypeResolverPool() + final type = Type.getType('[I') + + when: + final result = resolver.resolveType(type) + + then: + result == int[].class + } + + def 'test resolve primitive multidimensional array'() { + setup: + final resolver = new TypeResolverPool() + final type = Type.getType('[[[I') + + when: + final result = resolver.resolveType(type) + + then: + result == int[][][].class + } + + def 'test resolve class'() { + setup: + final resolver = new TypeResolverPool() + final type = Type.getType(String) + + when: + final result = resolver.resolveType(type) + + then: + result == String + } + + + def 'test resolve class array'() { + setup: + final resolver = new TypeResolverPool() + final type = Type.getType(String[]) + + when: + final result = resolver.resolveType(type) + + then: + result == String[] + } + + def 'test resolve class multidimensional array'() { + setup: + final resolver = new TypeResolverPool() + final type = Type.getType(String[][][]) + + when: + final result = resolver.resolveType(type) + + then: + result == String[][][] + } + + def 'test type resolver from method'() { + setup: + final resolver = new TypeResolverPool() + final type = Type.getMethodType(Type.getType(String[]), Type.getType(String), Type.getType(String)) + + when: + final result = resolver.resolveType(type.getReturnType()) + + then: + result == String[] + } + + def 'test inherited methods'() { + setup: + final resolver = new TypeResolverPool() + final owner = Type.getType(HttpServletRequest) + final name = 'getParameter' + final descriptor = Type.getMethodType(Type.getType(String), Type.getType(String)) + + when: + final result = resolver.resolveMethod(new MethodType(owner, name, descriptor)) + + then: + result == ServletRequest.getDeclaredMethod('getParameter', String) + } +} diff --git a/buildSrc/settings.gradle b/buildSrc/settings.gradle new file mode 100644 index 00000000000..a1bb223c587 --- /dev/null +++ b/buildSrc/settings.gradle @@ -0,0 +1 @@ +include ':call-site-instrumentation-plugin' diff --git a/buildSrc/src/main/groovy/CallSiteInstrumentationPlugin.groovy b/buildSrc/src/main/groovy/CallSiteInstrumentationPlugin.groovy new file mode 100644 index 00000000000..22e1b344ee0 --- /dev/null +++ b/buildSrc/src/main/groovy/CallSiteInstrumentationPlugin.groovy @@ -0,0 +1,205 @@ +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.SourceSet +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.compile.AbstractCompile +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.tasks.Jar +import org.gradle.jvm.toolchain.JavaLanguageVersion + +import java.nio.file.Paths + +import static groovy.io.FileType.FILES + +@CompileStatic +class CallSiteInstrumentationPlugin implements Plugin { + + @Override + void apply(final Project target) { + target.extensions.create('csi', CallSiteInstrumentationExtension) + target.afterEvaluate { + configureSourceSets(target) + createTasks(target) + } + } + + private static void configureSourceSets(final Project target) { + final extension = target.extensions.getByType(CallSiteInstrumentationExtension) + + // create a new source set for the csi files + final targetFolder = newBuildFolder(target, extension.targetFolder) + final sourceSets = getSourceSets(target) + final csiSourceSet = sourceSets.create('csi') + final mainSourceSet = sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME) + final csiConfiguration = target.configurations.getByName(csiSourceSet.compileClasspathConfigurationName) + final mainConfiguration = target.configurations.getByName(mainSourceSet.compileClasspathConfigurationName) + csiConfiguration.extendsFrom(mainConfiguration) + csiSourceSet.compileClasspath += mainSourceSet.output // mainly needed for the plugin tests + csiSourceSet.annotationProcessorPath += mainSourceSet.annotationProcessorPath + csiSourceSet.java.srcDir(targetFolder) + + // add csi classes to test classpath + final testSourceSet = sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME) + testSourceSet.compileClasspath += csiSourceSet.output.classesDirs + testSourceSet.runtimeClasspath += csiSourceSet.output.classesDirs + target.dependencies.add('testImplementation', csiSourceSet.output) + + // include classes in final JAR + target.tasks.named('jar').configure { Jar it -> it.from(csiSourceSet.output.classesDirs) } + } + + private static void createTasks(final Project target) { + final compileTask = (AbstractCompile) target.tasks.findByName('compileJava') + final extension = target.extensions.getByType(CallSiteInstrumentationExtension) + final input = compileTask.destinationDirectory + final output = target.layout.buildDirectory.dir(extension.targetFolder) + final targetFolder = output.get().asFile + createGenerateCallSiteTask(target, compileTask, input, output) + target.tasks.matching { Task task -> task.name.startsWith('compileTest') }.all { + final compileTestTask = (AbstractCompile) it + compileTestTask.classpath = compileTestTask.classpath + target.files(targetFolder) + } + target.tasks.matching { Task task -> task instanceof Test }.all { + final testTask = (Test) it + testTask.classpath = testTask.classpath + target.files(targetFolder) + } + } + + private static File newBuildFolder(final Project target, final String name) { + final folder = new File(target.buildDir, name) + if (folder.exists()) { + folder.traverse(type: FILES) { + if (!it.delete()) { + throw new GradleException("Cannot delete stale file $it") + } + } + } else { + if (!folder.mkdirs()) { + throw new GradleException("Cannot create folder $folder") + } + } + return folder + } + + private static File newTempFile(final File folder, final String name) { + final file = new File(folder, name) + file.deleteOnExit() + if (file.exists()) { + file.text = '' + } else if (!file.createNewFile()) { + throw new GradleException("Cannot create temporary file: $file") + } + return file + } + + private static void createGenerateCallSiteTask(final Project target, + final AbstractCompile compileTask, + final DirectoryProperty input, + final Provider output) { + final extension = target.extensions.getByType(CallSiteInstrumentationExtension) + final taskName = compileTask.name.replace('compile', 'generateCallSite') + final callSiteGeneratorTask = target.tasks.create(taskName, JavaExec) + final stdout = new ByteArrayOutputStream() + final stderr = new ByteArrayOutputStream() + callSiteGeneratorTask.group = 'call site instrumentation' + callSiteGeneratorTask.description = "Generates call sites from ${compileTask.name}" + if (extension.javaVersion != null) { + configureLanguage(target, callSiteGeneratorTask, extension.javaVersion) + } + callSiteGeneratorTask.setStandardOutput(stdout) + callSiteGeneratorTask.setErrorOutput(stderr) + callSiteGeneratorTask.inputs.dir(input) + callSiteGeneratorTask.outputs.dir(output) + callSiteGeneratorTask.mainClass.set('datadog.trace.plugin.csi.PluginApplication') + + final rootFolder = extension.rootFolder ?: target.rootDir + final path = Paths.get(rootFolder.toString(), + 'buildSrc', 'call-site-instrumentation-plugin', 'build', 'libs', 'call-site-instrumentation-plugin.jar') + callSiteGeneratorTask.jvmArgs(extension.jvmArgs) + callSiteGeneratorTask.classpath(path.toFile()) + callSiteGeneratorTask.setIgnoreExitValue(true) + // pass the arguments to the main via file to prevent issues with too long classpaths + callSiteGeneratorTask.doFirst { JavaExec execTask -> + final argumentFile = newTempFile(execTask.getTemporaryDir(), "call-site-arguments") + argumentFile.withWriter { + it.writeLine(input.get().asFile.toString()) + it.writeLine(output.get().asFile.toString()) + it.writeLine(extension.suffix); + it.writeLine(extension.reporters.join(',')) + getProgramClasspath(target).each { classpath -> it.writeLine(classpath.toString()) } + } + execTask.args(argumentFile.toString()) + } + callSiteGeneratorTask.doLast { JavaExec task -> + target.logger.info(stdout.toString()) + if (task.executionResult.get().exitValue != 0) { + target.logger.error(stderr.toString()) + throw new GradleException("Failed to generate call site classes, check task logs for more information") + } + } + + // insert task after compile + callSiteGeneratorTask.dependsOn(compileTask) + final sourceSets = getSourceSets(target) + final mainSourceSet = sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME) + target.tasks.named(mainSourceSet.classesTaskName).configure { it.dependsOn(callSiteGeneratorTask) } + + // compile generated sources + final csiSourceSet = sourceSets.getByName('csi') + target.tasks.named(csiSourceSet.compileJavaTaskName).configure { callSiteGeneratorTask.finalizedBy(it) } + } + + private static List getProgramClasspath(final Project project) { + final List classpath = [] + // 1. Compilation outputs + project.tasks.matching { Task task -> task instanceof AbstractCompile }.all { + final compileTask = (AbstractCompile) it + classpath.add(compileTask.destinationDirectory.getAsFile().get()) + } + // 2. Compile time dependencies + project.tasks.matching { Task task -> task instanceof AbstractCompile }.all { + final compileTask = (AbstractCompile) it + compileTask.classpath.every { classpath.add(it) } + } + // 3. Test time dependencies + project.tasks.matching { Task task -> task instanceof Test }.all { + final testTask = (Test) it + testTask.classpath.every { classpath.add(it) } + } + return classpath + } + + @CompileDynamic + private static SourceSetContainer getSourceSets(final Project target) { + return target.sourceSets + } + + @CompileDynamic + private static void configureLanguage(final Project target, final JavaExec task, final JavaLanguageVersion version) { + task.getJavaLauncher().set(target.javaToolchains.launcherFor { + languageVersion = version + }) + } +} + +@CompileStatic +class CallSiteInstrumentationExtension { + String suffix = 'CallSite' + String targetFolder = "generated${File.separatorChar}sources${File.separatorChar}csi" + List reporters = ['CONSOLE'] + File rootFolder + JavaLanguageVersion javaVersion + String[] jvmArgs = ['-Xmx128m', '-Xms64m'] +} + + + + diff --git a/buildSrc/src/test/groovy/CallSiteInstrumentationPluginTest.groovy b/buildSrc/src/test/groovy/CallSiteInstrumentationPluginTest.groovy new file mode 100644 index 00000000000..8989c95b127 --- /dev/null +++ b/buildSrc/src/test/groovy/CallSiteInstrumentationPluginTest.groovy @@ -0,0 +1,137 @@ +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.UnexpectedBuildFailure +import spock.lang.Specification +import spock.lang.TempDir + +class CallSiteInstrumentationPluginTest extends Specification { + + def buildGradle = ''' + plugins { + id 'java' + id 'call-site-instrumentation' + id("com.diffplug.spotless") version "5.11.0" + } + + csi { + suffix = 'CallSiteTest' + targetFolder = 'csi' + rootFolder = file('$$ROOT_FOLDER$$') + } + + repositories { + mavenCentral() + } + + dependencies { + implementation group: 'net.bytebuddy', name: 'byte-buddy', version: '1.12.12' + implementation group: 'com.google.auto.service', name: 'auto-service-annotations', version: '1.0-rc7' + } + ''' + + @TempDir + File buildDir + + def 'test call site instrumentation plugin'() { + setup: + createGradleProject(buildDir, buildGradle, ''' + import datadog.trace.agent.tooling.csi.*; + + @CallSite + public class BeforeAdviceCallSiteTest { + @CallSite.Before("java.lang.StringBuilder java.lang.StringBuilder.append(java.lang.String)") + public static void beforeAppend(@CallSite.This final StringBuilder self, @CallSite.Argument final String param) { + } + } + ''') + + when: + final result = buildGradleProject(buildDir) + + then: + final generated = resolve(buildDir, 'build', 'csi', 'BeforeAdviceCallSiteTestBeforeAppend.java') + generated.exists() + + final output = result.output + !output.contains('[⨉]') + output.contains('BeforeAdviceCallSiteTest') + output.contains('beforeAppend') + output.contains('java.lang.StringBuilder java.lang.StringBuilder.append(java.lang.String)') // pointcut + } + + def 'test call site instrumentation plugin with error'() { + setup: + createGradleProject(buildDir, buildGradle, ''' + import datadog.trace.agent.tooling.csi.*; + + @CallSite + public class BeforeAdviceCallSiteTest { + @CallSite.Before("java.lang.StringBuilder java.lang.StringBuilder.append(java.lang.String)") + private void beforeAppend(@CallSite.This final StringBuilder self, @CallSite.Argument final String param) { + } + } + ''') + + when: + buildGradleProject(buildDir) + + then: + final error = thrown(UnexpectedBuildFailure) + + final generated = resolve(buildDir, 'build', 'csi', 'BeforeAdviceCallSiteTest$BeforeAppend.java') + !generated.exists() + + final output = error.message + !output.contains('[✓]') + output.contains('ADVICE_METHOD_NOT_STATIC_AND_PUBLIC') + } + + private static void createGradleProject(final File buildDir, final String gradleFile, final String advice) { + final projectFolder = new File(System.getProperty('user.dir')).parentFile + final callSiteJar = resolve(projectFolder, 'buildSrc', 'call-site-instrumentation-plugin') + final gradleFileContent = gradleFile.replace('$$ROOT_FOLDER$$', projectFolder.toString()) + + final buildGradle = resolve(buildDir, 'build.gradle') + buildGradle.text = gradleFileContent + + final javaFolder = resolve(buildDir, 'src', 'main', 'java') + javaFolder.mkdirs() + + final advicePackage = parsePackage(advice) + final adviceClassName = parseClassName(advice) + final adviceFolder = resolve(javaFolder, advicePackage.split('\\.')) + adviceFolder.mkdirs() + final adviceFile = resolve(adviceFolder, "${adviceClassName}.java") + adviceFile.text = advice + + final csiSource = resolve(projectFolder, 'dd-java-agent', 'agent-tooling', 'src', 'main', 'java', 'datadog', 'trace', 'agent', 'tooling', 'csi') + final csiTarget = resolve(javaFolder, 'datadog', 'trace', 'agent', 'tooling', 'csi') + csiTarget.mkdirs() + csiSource.listFiles().each { new File(csiTarget, it.name).text = it.text } + } + + private static BuildResult buildGradleProject(final File buildDir) { + return GradleRunner.create() + .withTestKitDir(new File(buildDir, '.gradle-test-kit')) // workaround in case the global test-kit cache becomes corrupted + .withDebug(true) // avoids starting daemon which can leave undeleted files post-cleanup + .withProjectDir(buildDir) + .withArguments('build', '--info', '--stacktrace') + .withPluginClasspath() + .forwardOutput() + .build() + } + + private static String parsePackage(final String advice) { + final advicePackageMatcher = advice =~ /(?s).*package\s+([\w\.]+)\s*;/ + return advicePackageMatcher ? advicePackageMatcher[0][1] as String : '' + } + + private static String parseClassName(final String advice) { + return (advice =~ /(?s).*class\s+(\w+)\s+\{\.*/)[0][1] + } + + private static File resolve(final File file, final String...path) { + final result = path.inject(file.toPath()) {parent, folder -> parent.resolve(folder)} + return result.toFile() + } +} diff --git a/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/csi/CallSite.java b/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/csi/CallSite.java new file mode 100644 index 00000000000..ed30080ddf9 --- /dev/null +++ b/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/csi/CallSite.java @@ -0,0 +1,97 @@ +package datadog.trace.agent.tooling.csi; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * TODO when project is migrated to JDK8 review the possibility to use + * java.lang.annotation.Repeatable annotations + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.CLASS) +public @interface CallSite { + + /** Interface to be used for SPI injection, by default {@link CallSiteAdvice} */ + Class spi() default CallSiteAdvice.class; + + /** Helper classes for the advice */ + Class[] helpers() default {}; + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @interface After { + /** + * Pointcut expression for the advice (e.g. {@code java.lang.StringBuilder + * java.lang.StringBuilder.append(java.lang.String)}) + */ + String value(); + + boolean invokeDynamic() default false; + } + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @interface AfterArray { + After[] value(); + } + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @interface Around { + /** + * Pointcut expression for the advice (e.g. {@code java.lang.StringBuilder + * java.lang.StringBuilder.append(java.lang.String)}) + */ + String value(); + + boolean invokeDynamic() default false; + } + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @interface AroundArray { + Around[] value(); + } + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @interface Before { + /** + * Pointcut expression for the advice (e.g. {@code java.lang.StringBuilder + * java.lang.StringBuilder.append(java.lang.String)}) + */ + String value(); + + boolean invokeDynamic() default false; + } + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @interface BeforeArray { + Before[] value(); + } + + @Target(ElementType.PARAMETER) + @Retention(RetentionPolicy.CLASS) + @interface This {} + + @Target(ElementType.PARAMETER) + @Retention(RetentionPolicy.CLASS) + @interface Argument {} + + @Target(ElementType.PARAMETER) + @Retention(RetentionPolicy.CLASS) + @interface AllArguments { + boolean includeThis() default false; + } + + @Target(ElementType.PARAMETER) + @Retention(RetentionPolicy.CLASS) + @interface InvokeDynamicConstants {} + + @Target(ElementType.PARAMETER) + @Retention(RetentionPolicy.CLASS) + @interface Return {} +}