diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d53300a..5e06974c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 3.2.1 +### Features + +- Populate events with dependencies metadata ([#396](https://github.com/getsentry/sentry-android-gradle-plugin/pull/396)) + ### Fixes - Ignore minified classes from any instrumentation ([#389](https://github.com/getsentry/sentry-android-gradle-plugin/pull/389)) diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 52815d06..070da917 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -1,3 +1,4 @@ +import BuildPluginsVersion.SPRING_BOOT import org.gradle.util.VersionNumber object BuildPluginsVersion { @@ -10,6 +11,11 @@ object BuildPluginsVersion { // build/publications/maven const val MAVEN_PUBLISH = "0.17.0" const val PROGUARD = "7.1.0" + const val GROOVY_REDISTRIBUTED = "1.2" + + const val SPRING_BOOT = "2.7.4" + const val SPRING_DEP_MANAGEMENT = "1.0.11.RELEASE" + // proguard does not support AGP 8 yet fun isProguardApplicable(): Boolean = VersionNumber.parse(AGP).major < 8 } @@ -28,6 +34,10 @@ object Libs { val AGP = "com.android.tools.build:gradle:${BuildPluginsVersion.AGP}" const val JUNIT = "junit:junit:${LibsVersion.JUNIT}" const val PROGUARD = "com.guardsquare:proguard-gradle:${BuildPluginsVersion.PROGUARD}" + // this allows us to develop against a fixed version of Gradle, as opposed to depending on the + // locally available version. kotlin-gradle-plugin follows the same approach. + // More info: https://docs.nokee.dev/manual/gradle-plugin-development-plugin.html + const val GRADLE_API = "dev.gradleplugins:gradle-api:7.5" // bytecode instrumentation const val ASM = "org.ow2.asm:asm-util:${LibsVersion.ASM}" @@ -83,4 +93,20 @@ object Samples { private const val version = "1.3.5" const val fragmentKtx = "androidx.fragment:fragment-ktx:${version}" } + + object SpringBoot { + val springBoot = "org.springframework.boot" + val springDependencyManagement = "io.spring.dependency-management" + val springBootStarter = "org.springframework.boot:spring-boot-starter:$SPRING_BOOT" + val springBootStarterTest = "org.springframework.boot:spring-boot-starter-test:$SPRING_BOOT" + val springBootStarterWeb = "org.springframework.boot:spring-boot-starter-web:$SPRING_BOOT" + val springBootStarterWebflux = "org.springframework.boot:spring-boot-starter-webflux:$SPRING_BOOT" + val springBootStarterAop = "org.springframework.boot:spring-boot-starter-aop:$SPRING_BOOT" + val springBootStarterSecurity = "org.springframework.boot:spring-boot-starter-security:$SPRING_BOOT" + val springBootStarterJdbc = "org.springframework.boot:spring-boot-starter-jdbc:$SPRING_BOOT" + val hsqldb = "org.hsqldb:hsqldb:2.6.1" + val aspectj = "org.aspectj:aspectjweaver" + val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" + val kotlinStdLib = "stdlib-jdk8" + } } diff --git a/examples/spring-boot-sample/build.gradle.kts b/examples/spring-boot-sample/build.gradle.kts new file mode 100644 index 00000000..becd71ed --- /dev/null +++ b/examples/spring-boot-sample/build.gradle.kts @@ -0,0 +1,51 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id(Samples.SpringBoot.springBoot) version BuildPluginsVersion.SPRING_BOOT + id(Samples.SpringBoot.springDependencyManagement) version BuildPluginsVersion.SPRING_DEP_MANAGEMENT + kotlin("jvm") + kotlin("plugin.spring") version BuildPluginsVersion.KOTLIN + id("io.sentry.android.gradle") +} + +group = "io.sentry.samples.spring-boot" +version = "0.0.1-SNAPSHOT" +java.sourceCompatibility = JavaVersion.VERSION_1_8 +java.targetCompatibility = JavaVersion.VERSION_1_8 + +repositories { + mavenCentral() +} + +dependencies { + implementation(Samples.SpringBoot.springBootStarterSecurity) + implementation(Samples.SpringBoot.springBootStarterWeb) + implementation(Samples.SpringBoot.springBootStarterWebflux) + implementation(Samples.SpringBoot.springBootStarterAop) + implementation(Samples.SpringBoot.aspectj) + implementation(Samples.SpringBoot.springBootStarter) + implementation(Samples.SpringBoot.kotlinReflect) + implementation(Samples.SpringBoot.springBootStarterJdbc) + implementation(kotlin(Samples.SpringBoot.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation("io.sentry:sentry-spring-boot-starter:6.5.0") + implementation("io.sentry:sentry-logback:6.5.0") + + // database query tracing + implementation("io.sentry:sentry-jdbc:6.5.0") + runtimeOnly(Samples.SpringBoot.hsqldb) + testImplementation(Samples.SpringBoot.springBootStarterTest) { + exclude(group = "org.junit.vintage", module = "junit-vintage-engine") + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = JavaVersion.VERSION_1_8.toString() + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/CustomEventProcessor.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/CustomEventProcessor.java new file mode 100644 index 00000000..b74310a6 --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.protocol.SentryRuntime; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.SpringBootVersion; +import org.springframework.stereotype.Component; + +/** + * Custom {@link EventProcessor} implementation lets modifying {@link SentryEvent}s before they are + * sent to Sentry. + */ +@Component +public class CustomEventProcessor implements EventProcessor { + private final String springBootVersion; + + public CustomEventProcessor(String springBootVersion) { + this.springBootVersion = springBootVersion; + } + + public CustomEventProcessor() { + this(SpringBootVersion.getVersion()); + } + + @Override + public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + final SentryRuntime runtime = new SentryRuntime(); + runtime.setVersion(springBootVersion); + runtime.setName("Spring Boot"); + event.getContexts().setRuntime(runtime); + return event; + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/CustomJob.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/CustomJob.java new file mode 100644 index 00000000..cdebaeac --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/CustomJob.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.spring.tracing.SentryTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * {@link SentryTransaction} added on the class level, creates transaction around each method + * execution of every method of the annotated class. + */ +@Component +@SentryTransaction(operation = "scheduled") +public class CustomJob { + + private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); + + @Scheduled(fixedRate = 3 * 1000L) + void execute() throws InterruptedException { + LOGGER.info("Executing scheduled job"); + Thread.sleep(2000L); + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/Person.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/Person.java new file mode 100644 index 00000000..2a2177d4 --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot; + +public class Person { + private final String firstName; + private final String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/PersonController.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/PersonController.java new file mode 100644 index 00000000..c8245153 --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/PersonController.java @@ -0,0 +1,32 @@ +package io.sentry.samples.spring.boot; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/person/") +public class PersonController { + private final PersonService personService; + private static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + public PersonController(PersonService personService) { + this.personService = personService; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + LOGGER.info("Loading person with id={}", id); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } + + @PostMapping + Person create(@RequestBody Person person) { + return personService.create(person); + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/PersonService.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/PersonService.java new file mode 100644 index 00000000..0eb67e85 --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/PersonService.java @@ -0,0 +1,40 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring.tracing.SentrySpan; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * {@link SentrySpan} can be added either on the class or the method to create spans around method + * executions. + */ +@Service +@SentrySpan +public class PersonService { + private static final Logger LOGGER = LoggerFactory.getLogger(PersonService.class); + + private final JdbcTemplate jdbcTemplate; + private int createCount = 0; + + public PersonService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + Person create(Person person) { + createCount++; + final ISpan span = Sentry.getSpan(); + if (span != null) { + span.setMeasurement("create_count", createCount); + } + + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.getFirstName(), + person.getLastName()); + return person; + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/SecurityConfiguration.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/SecurityConfiguration.java new file mode 100644 index 00000000..f2e06880 --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/SecurityConfiguration.java @@ -0,0 +1,42 @@ +package io.sentry.samples.spring.boot; + +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +@Configuration +@SuppressWarnings("deprecation") // WebSecurityConfigurerAdapter has been deprecated +public class SecurityConfiguration + extends org.springframework.security.config.annotation.web.configuration + .WebSecurityConfigurerAdapter { + + // this API is meant to be consumed by non-browser clients thus the CSRF protection is not needed. + @Override + @SuppressWarnings("lgtm[java/spring-disabled-csrf-protection]") + protected void configure(final @NotNull HttpSecurity http) throws Exception { + http.csrf().disable().authorizeRequests().anyRequest().authenticated().and().httpBasic(); + } + + @Bean + @Override + public @NotNull UserDetailsService userDetailsService() { + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + final UserDetails user = + User.builder() + .passwordEncoder(encoder::encode) + .username("user") + .password("password") + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java new file mode 100644 index 00000000..18afe283 --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java @@ -0,0 +1,27 @@ +package io.sentry.samples.spring.boot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +@EnableScheduling +public class SentryDemoApplication { + public static void main(String[] args) { + SpringApplication.run(SentryDemoApplication.class, args); + } + + @Bean + RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } + + @Bean + WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/Todo.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/Todo.java new file mode 100644 index 00000000..e9b02ece --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot; + +public class Todo { + private final Long id; + private final String title; + private final boolean completed; + + public Todo(Long id, String title, boolean completed) { + this.id = id; + this.title = title; + this.completed = completed; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public boolean isCompleted() { + return completed; + } +} diff --git a/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/TodoController.java b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/TodoController.java new file mode 100644 index 00000000..10d97bbf --- /dev/null +++ b/examples/spring-boot-sample/src/main/java/io/sentry/samples/spring/boot/TodoController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +@RestController +public class TodoController { + private final RestTemplate restTemplate; + private final WebClient webClient; + + public TodoController(RestTemplate restTemplate, WebClient webClient) { + this.restTemplate = restTemplate; + this.webClient = webClient; + } + + @GetMapping("/todo/{id}") + Todo todo(@PathVariable Long id) { + return restTemplate.getForObject( + "https://jsonplaceholder.typicode.com/todos/{id}", Todo.class, id); + } + + @GetMapping("/todo-webclient/{id}") + Todo todoWebClient(@PathVariable Long id) { + return webClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .bodyToMono(Todo.class) + .block(); + } +} diff --git a/examples/spring-boot-sample/src/main/resources/application.properties b/examples/spring-boot-sample/src/main/resources/application.properties new file mode 100644 index 00000000..b7f13ee9 --- /dev/null +++ b/examples/spring-boot-sample/src/main/resources/application.properties @@ -0,0 +1,19 @@ +# NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard +sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 +sentry.send-default-pii=true +sentry.max-request-body-size=medium +# Sentry Spring Boot integration allows more fine-grained SentryOptions configuration +sentry.max-breadcrumbs=150 +# Logback integration configuration options +sentry.logging.minimum-event-level=info +sentry.logging.minimum-breadcrumb-level=debug +# Performance configuration +sentry.traces-sample-rate=1.0 +sentry.debug=true +in-app-includes="io.sentry.samples" + +# Database configuration +spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb +spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver +spring.datasource.username=sa +spring.datasource.password= diff --git a/examples/spring-boot-sample/src/main/resources/schema.sql b/examples/spring-boot-sample/src/main/resources/schema.sql new file mode 100644 index 00000000..7ca8a5cb --- /dev/null +++ b/examples/spring-boot-sample/src/main/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); diff --git a/plugin-build/build.gradle.kts b/plugin-build/build.gradle.kts index 7cb58507..85c6d6ad 100644 --- a/plugin-build/build.gradle.kts +++ b/plugin-build/build.gradle.kts @@ -7,9 +7,9 @@ import org.gradle.configurationcache.extensions.serviceOf import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { + id("dev.gradleplugins.groovy-gradle-plugin") version BuildPluginsVersion.GROOVY_REDISTRIBUTED kotlin("jvm") version BuildPluginsVersion.KOTLIN id("distribution") - id("groovy") id("org.jetbrains.dokka") version BuildPluginsVersion.DOKKA id("java-gradle-plugin") id("com.vanniktech.maven.publish") version BuildPluginsVersion.MAVEN_PUBLISH apply false @@ -30,7 +30,7 @@ val androidSdkPath: String? by extra val testImplementationAar by configurations.getting // this converts .aar into .jar dependencies dependencies { - compileOnly(gradleApi()) + compileOnly(Libs.GRADLE_API) compileOnly(Libs.AGP) compileOnly(Libs.PROGUARD) @@ -62,9 +62,8 @@ dependencies { } configure { - toolchain { - languageVersion.set(JavaLanguageVersion.of(11)) - } + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } // We need to compile Groovy first and let Kotlin depend on it. diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryPlugin.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryPlugin.kt index 1bf722d7..f3296655 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryPlugin.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryPlugin.kt @@ -10,16 +10,20 @@ import io.sentry.android.gradle.SentryPropertiesFileProvider.getPropertiesFilePa import io.sentry.android.gradle.SentryTasksProvider.capitalized import io.sentry.android.gradle.SentryTasksProvider.getAssembleTaskProvider import io.sentry.android.gradle.SentryTasksProvider.getBundleTask +import io.sentry.android.gradle.SentryTasksProvider.getLintVitalAnalyzeProvider +import io.sentry.android.gradle.SentryTasksProvider.getLintVitalReportProvider import io.sentry.android.gradle.SentryTasksProvider.getMappingFileProvider import io.sentry.android.gradle.SentryTasksProvider.getMergeAssetsProvider import io.sentry.android.gradle.SentryTasksProvider.getPackageBundleTask import io.sentry.android.gradle.SentryTasksProvider.getPackageProvider import io.sentry.android.gradle.SentryTasksProvider.getPreBundleTask +import io.sentry.android.gradle.SentryTasksProvider.getProcessResourcesProvider import io.sentry.android.gradle.SentryTasksProvider.getTransformerTask import io.sentry.android.gradle.autoinstall.installDependencies import io.sentry.android.gradle.extensions.SentryPluginExtension import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory import io.sentry.android.gradle.services.SentryModulesService +import io.sentry.android.gradle.tasks.SentryExternalDependenciesReportTask import io.sentry.android.gradle.tasks.SentryGenerateProguardUuidTask import io.sentry.android.gradle.tasks.SentryUploadNativeSymbolsTask import io.sentry.android.gradle.tasks.SentryUploadProguardMappingsTask @@ -33,10 +37,14 @@ import io.sentry.android.gradle.util.SentryPluginUtils.withLogging import io.sentry.android.gradle.util.detectSentryAndroidSdk import io.sentry.android.gradle.util.info import java.io.File +import java.util.concurrent.atomic.AtomicBoolean import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.file.RegularFile import org.gradle.api.plugins.ExtraPropertiesExtension +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.provider.Provider import org.gradle.api.tasks.StopExecutionException import org.gradle.api.tasks.TaskProvider import org.slf4j.LoggerFactory @@ -44,6 +52,13 @@ import org.slf4j.LoggerFactory @Suppress("UnstableApiUsage") class SentryPlugin : Plugin { + /** + * Since we're listening for the JavaBasePlugin, there may be multiple plugins inherting from it + * applied to the same project, e.g. Spring Boot + Kotlin Jvm, hence we only want our plugin to + * be configured only once. + */ + private val configuredForJavaProject = AtomicBoolean(false) + override fun apply(project: Project) { if (AgpVersions.CURRENT < AgpVersions.VERSION_7_0_0) { throw StopExecutionException( @@ -172,6 +187,19 @@ class SentryPlugin : Plugin { var transformerTaskProvider: TaskProvider? = null var packageBundleTaskProvider: TaskProvider? = null + val mergeAssetsDependants = setOf( + getMergeAssetsProvider(variant), + // lint vital tasks scan the entire "build" folder; since we're writing our + // generated stuff in there, we put explicit dependency on them to avoid + // warnings about implicit dependency + withLogging(project.logger, "lintVitalAnalyzeTask") { + getLintVitalAnalyzeProvider(project, variant.name) + }, + withLogging(project.logger, "lintVitalReportTask") { + getLintVitalReportProvider(project, variant.name) + } + ) + if (isMinificationEnabled) { preBundleTaskProvider = withLogging(project.logger, "preBundleTask") { getPreBundleTask(project, variant.name) @@ -193,6 +221,22 @@ class SentryPlugin : Plugin { } val taskSuffix = variant.name.capitalizeUS() + val sentryAssetDir = + project.layout.buildDirectory.dir( + "generated${sep}assets${sep}sentry${sep}${variant.name}" + ) + androidExtension.sourceSets.getByName(variant.name).assets.srcDir(sentryAssetDir) + + val reportDependenciesTask = project.registerDependenciesTask( + configurationName = "${variant.name}RuntimeClasspath", + attributeValueJar = "android-classes", + includeReport = extension.includeDependenciesReport, + output = sentryAssetDir.flatMap { dir -> + dir.file(project.provider { SENTRY_DEPENDENCIES_REPORT_OUTPUT }) + }, + taskSuffix = taskSuffix + ) + reportDependenciesTask.setupMergeAssetsDependencies(mergeAssetsDependants) if (isMinificationEnabled && extension.includeProguardMapping.get()) { // Setup the task to generate a UUID asset file @@ -200,18 +244,13 @@ class SentryPlugin : Plugin { "generateSentryProguardUuid$taskSuffix", SentryGenerateProguardUuidTask::class.java ) { - it.outputDirectory.set( - project.file( - File( - project.buildDir, - "generated${sep}assets${sep}sentry${sep}${variant.name}" - ) - ) + it.output.set( + sentryAssetDir.flatMap { dir -> + dir.file(project.provider { "sentry-debug-meta.properties" }) + } ) } - getMergeAssetsProvider(variant)?.configure { - it.dependsOn(generateUuidTask) - } + generateUuidTask.setupMergeAssetsDependencies(mergeAssetsDependants) // Setup the task that uploads the proguard mapping and UUIDs val uploadSentryProguardMappingsTask = project.tasks.register( @@ -224,7 +263,7 @@ class SentryPlugin : Plugin { task.sentryProperties.set( sentryProperties?.let { file -> project.file(file) } ) - task.uuidDirectory.set(generateUuidTask.flatMap { it.outputDirectory }) + task.uuidFile.set(generateUuidTask.flatMap { it.output }) task.mappingsFiles = getMappingFileProvider( project, variant, @@ -234,9 +273,6 @@ class SentryPlugin : Plugin { task.sentryOrganization.set(sentryOrgParameter) task.sentryProject.set(sentryProjectParameter) } - androidExtension.sourceSets.getByName(variant.name).assets.srcDir( - generateUuidTask.flatMap { it.outputDirectory } - ) if (extension.experimentalGuardsquareSupport.get() && GroovyCompat.isDexguardEnabledForVariant(project, variant.name) @@ -308,6 +344,72 @@ class SentryPlugin : Plugin { project.installDependencies(extension) } + + project.pluginManager.withPlugin("org.gradle.java") { + if (project.pluginManager.hasPlugin("com.android.application")) { + // AGP also applies JavaBasePlugin, but since we have a separate setup for it, + // we just bail here + logger.info { "The Sentry Gradle plugin was already configured for AGP" } + return@withPlugin + } + if (configuredForJavaProject.getAndSet(true)) { + logger.info { "The Sentry Gradle plugin was already configured" } + return@withPlugin + } + + val javaExtension = project.extensions.getByType(JavaPluginExtension::class.java) + + val sentryResDir = project.layout.buildDirectory.dir("generated${sep}sentry") + javaExtension.sourceSets.getByName("main").resources { sourceSet -> + sourceSet.srcDir(sentryResDir) + } + + val reportDependenciesTask = project.registerDependenciesTask( + configurationName = "runtimeClasspath", + attributeValueJar = "jar", + includeReport = extension.includeDependenciesReport, + output = sentryResDir.flatMap { dir -> + dir.file( + project.provider { SENTRY_DEPENDENCIES_REPORT_OUTPUT } + ) + } + ) + val resourcesTask = withLogging(project.logger, "processResources") { + getProcessResourcesProvider(project) + } + resourcesTask?.configure { task -> task.dependsOn(reportDependenciesTask) } + } + } + + private fun Project.registerDependenciesTask( + configurationName: String, + attributeValueJar: String, + output: Provider, + includeReport: Provider, + taskSuffix: String = "" + ): TaskProvider { + val reportDependenciesTask = tasks.register( + "collectExternal${taskSuffix}DependenciesForSentry", + SentryExternalDependenciesReportTask::class.java + ) { + it.includeReport.set(includeReport) + it.attributeValueJar.set(attributeValueJar) + it.setRuntimeConfiguration( + project.configurations.getByName(configurationName) + ) + it.output.set(output) + } + return reportDependenciesTask + } + + private fun TaskProvider.setupMergeAssetsDependencies( + dependants: Set?> + ) { + dependants.forEach { + it?.configure { task -> + task.dependsOn(this) + } + } } private fun isVariantAllowed( @@ -325,6 +427,7 @@ class SentryPlugin : Plugin { const val SENTRY_ORG_PARAMETER = "sentryOrg" const val SENTRY_PROJECT_PARAMETER = "sentryProject" internal const val SENTRY_SDK_VERSION = "6.6.0" + internal const val SENTRY_DEPENDENCIES_REPORT_OUTPUT = "sentry-external-modules.txt" internal val sep = File.separator diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryTasksProvider.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryTasksProvider.kt index bd3499ac..671184d4 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryTasksProvider.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/SentryTasksProvider.kt @@ -3,6 +3,7 @@ package io.sentry.android.gradle import com.android.build.gradle.api.ApplicationVariant import com.android.build.gradle.tasks.MergeSourceSetFolders import com.android.build.gradle.tasks.PackageAndroidArtifact +import io.sentry.android.gradle.SentryTasksProvider.capitalized import io.sentry.android.gradle.util.GroovyCompat.isDexguardAvailable import io.sentry.android.gradle.util.SentryPluginUtils.capitalizeUS import java.io.File @@ -138,6 +139,32 @@ internal object SentryTasksProvider { // for App Bundle it uses getPackageBundleTask variant.packageApplicationProvider + /** + * Returns the lintVitalAnalyze task provider + * + * @return the provider if found or null otherwise + */ + @JvmStatic + fun getLintVitalAnalyzeProvider(project: Project, variantName: String) = + project.findTask(listOf("lintVitalAnalyze${variantName.capitalized}")) + + /** + * Returns the lintVitalReport task provider + * + * @return the provider if found or null otherwise + */ + @JvmStatic + fun getLintVitalReportProvider(project: Project, variantName: String) = + project.findTask(listOf("lintVitalReport${variantName.capitalized}")) + + /** + * Returns the processResources task provider + * + * @return the provider if found or null otherwise + */ + @JvmStatic + fun getProcessResourcesProvider(project: Project) = project.findTask(listOf("processResources")) + private fun Project.findTask(taskName: List): TaskProvider? = taskName .mapNotNull { diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt index 0a662187..26bd87d2 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/extensions/SentryPluginExtension.kt @@ -119,4 +119,15 @@ abstract class SentryPluginExtension @Inject constructor(project: Project) { ) { autoInstallationAction.execute(autoInstallation) } + + /** + * Disables or enables the reporting of dependencies metadata for Sentry. + * If enabled the plugin will collect external dependencies and will take care of + * uploading them to Sentry as part of events. If disabled, all the logic + * related to dependencies metadata report will be excluded. + * + * Default is enabled. + */ + val includeDependenciesReport: Property = objects.property(Boolean::class.java) + .convention(true) } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryExternalDependenciesReportTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryExternalDependenciesReportTask.kt new file mode 100644 index 00000000..abc4c4b0 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryExternalDependenciesReportTask.kt @@ -0,0 +1,75 @@ +package io.sentry.android.gradle.tasks + +import io.sentry.android.gradle.util.GradleVersions +import io.sentry.android.gradle.util.SentryPluginUtils +import io.sentry.android.gradle.util.artifactsFor +import org.gradle.api.DefaultTask +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.file.FileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +@CacheableTask +abstract class SentryExternalDependenciesReportTask : DefaultTask() { + + @get:Input + abstract val includeReport: Property + + init { + description = "Generates an external dependencies report" + + if (GradleVersions.CURRENT >= GradleVersions.VERSION_7_4) { + @Suppress("LeakingThis") + notCompatibleWithConfigurationCache("Cannot serialize Configurations") + } + @Suppress("LeakingThis") + onlyIf { includeReport.get() } + } + + @Transient + private lateinit var runtimeConfiguration: Configuration + + fun setRuntimeConfiguration(configuration: Configuration) { + runtimeConfiguration = configuration + } + + @get:Input + abstract val attributeValueJar: Property + + // this is a proper input, so our task gets triggered whenever the dependency set changes + @PathSensitive(PathSensitivity.NAME_ONLY) + @InputFiles + fun getRuntimeClasspath(): FileCollection = runtimeConfiguration.artifactsFor( + attributeValueJar.get() + ) + + @get:OutputFile + abstract val output: RegularFileProperty + + @TaskAction + fun action() { + val outputFile = SentryPluginUtils.getAndDeleteFile(output) + outputFile.parentFile.mkdirs() + + val dependencies = runtimeConfiguration + .incoming + .resolutionResult + .allComponents + // we're only interested in external deps + .filter { it.id is ModuleComponentIdentifier } + // and those that have proper version defined (e.g. flat jars don't have it) + .filter { it.moduleVersion?.version?.isNotEmpty() == true } + .map { it.id.displayName } + .toSortedSet() + + outputFile.writeText(dependencies.joinToString("\n")) + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryGenerateProguardUuidTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryGenerateProguardUuidTask.kt index 24debdd4..be5b43da 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryGenerateProguardUuidTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryGenerateProguardUuidTask.kt @@ -3,11 +3,8 @@ package io.sentry.android.gradle.tasks import io.sentry.android.gradle.util.info import java.util.UUID import org.gradle.api.DefaultTask -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFile -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction abstract class SentryGenerateProguardUuidTask : DefaultTask() { @@ -18,23 +15,18 @@ abstract class SentryGenerateProguardUuidTask : DefaultTask() { "when uploading the Sentry mapping file" } - @get:OutputDirectory - abstract val outputDirectory: DirectoryProperty - - @get:Internal - val outputFile: Provider get() = outputDirectory.file( - "sentry-debug-meta.properties" - ) + @get:OutputFile + abstract val output: RegularFileProperty @TaskAction fun generateProperties() { logger.info { - "SentryGenerateProguardUuidTask - outputFile: ${outputFile.get()}" + "SentryGenerateProguardUuidTask - outputFile: ${output.get()}" } UUID.randomUUID().also { - outputFile.get().asFile.parentFile.mkdirs() - outputFile.get().asFile.writeText("io.sentry.ProguardUuids=$it") + output.get().asFile.parentFile.mkdirs() + output.get().asFile.writeText("io.sentry.ProguardUuids=$it") } } } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryUploadProguardMappingsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryUploadProguardMappingsTask.kt index 436fe20f..f918c67e 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryUploadProguardMappingsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/tasks/SentryUploadProguardMappingsTask.kt @@ -3,18 +3,14 @@ package io.sentry.android.gradle.tasks import java.io.File import org.apache.tools.ant.taskdefs.condition.Os import org.apache.tools.ant.taskdefs.condition.Os.FAMILY_WINDOWS -import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.FileCollection -import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional abstract class SentryUploadProguardMappingsTask : Exec() { @@ -26,12 +22,8 @@ abstract class SentryUploadProguardMappingsTask : Exec() { @get:Input abstract val cliExecutable: Property - @get:InputDirectory - abstract val uuidDirectory: DirectoryProperty - - @get:Internal - val uuidFile: Provider - get() = uuidDirectory.file("sentry-debug-meta.properties") + @get:InputFile + abstract val uuidFile: RegularFileProperty @get:InputFiles abstract var mappingsFiles: Provider diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/ArtifactUtils.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/ArtifactUtils.kt new file mode 100644 index 00000000..b7f19d2b --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/ArtifactUtils.kt @@ -0,0 +1,28 @@ +package io.sentry.android.gradle.util + +import org.gradle.api.artifacts.ArtifactView +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.attributes.Attribute + +/** + * Adapted from https://gist.github.com/autonomousapps/f0133e58a612b6837f3f4f6554337035 + */ + +private val attributeKey: Attribute = Attribute.of("artifactType", String::class.java) + +fun Configuration.artifactsFor( + attrValue: String +) = externalArtifactViewOf(attrValue) + .artifacts + .artifactFiles + +fun Configuration.externalArtifactViewOf( + attrValue: String +): ArtifactView = incoming.artifactView { view -> + view.attributes.attribute(attributeKey, attrValue) + // If some dependency doesn't have the expected attribute, don't fail. Continue! + view.lenient(true) + // Only resolve external dependencies! Without this, all project dependencies will get _compiled_. + view.componentFilter { id -> id is ModuleComponentIdentifier } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryPluginUtils.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryPluginUtils.kt index 5140912d..05d290aa 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryPluginUtils.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/SentryPluginUtils.kt @@ -2,10 +2,13 @@ package io.sentry.android.gradle.util import com.android.build.gradle.api.ApplicationVariant import io.sentry.android.gradle.util.GroovyCompat.isDexguardEnabledForVariant +import java.io.File import java.util.Locale import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.file.RegularFile import org.gradle.api.logging.Logger +import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider import proguard.gradle.plugin.android.dsl.ProGuardAndroidExtension @@ -49,4 +52,10 @@ internal object SentryPluginUtils { } return variant.buildType.isMinifyEnabled } + + fun getAndDeleteFile(property: Provider): File { + val file = property.get().asFile + file.delete() + return file + } } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt index 7822af7a..1992fd7e 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/util/Versions.kt @@ -3,6 +3,7 @@ package io.sentry.android.gradle.util import com.android.builder.model.Version import io.sentry.android.gradle.util.SemVer.Companion.equals import kotlin.math.min +import org.gradle.util.GradleVersion internal object AgpVersions { val CURRENT: SemVer = SemVer.parse(Version.ANDROID_GRADLE_PLUGIN_VERSION) @@ -10,6 +11,11 @@ internal object AgpVersions { val VERSION_7_0_0: SemVer = SemVer.parse("7.0.0") } +internal object GradleVersions { + val CURRENT: SemVer = SemVer.parse(GradleVersion.current().version) + val VERSION_7_4: SemVer = SemVer.parse("7.4") +} + internal object SentryVersions { internal val VERSION_DEFAULT = SemVer() internal val VERSION_PERFORMANCE = SemVer(4, 0, 0) diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/BaseSentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/BaseSentryPluginTest.kt index 3e093767..ca0df71f 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/BaseSentryPluginTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/BaseSentryPluginTest.kt @@ -20,6 +20,7 @@ abstract class BaseSentryPluginTest( private lateinit var rootBuildFile: File protected lateinit var appBuildFile: File + protected lateinit var moduleBuildFile: File protected lateinit var runner: GradleRunner protected open val additionalRootProjectConfig: String = "" @@ -34,6 +35,7 @@ abstract class BaseSentryPluginTest( .replace(File.separator, "/") appBuildFile = File(testProjectDir.root, "app/build.gradle") + moduleBuildFile = File(testProjectDir.root, "module/build.gradle") rootBuildFile = testProjectDir.writeFile("build.gradle") { // language=Groovy """ diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginAutoInstallTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginAutoInstallTest.kt index 5dfbe8e9..382be5ed 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginAutoInstallTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginAutoInstallTest.kt @@ -18,7 +18,10 @@ class SentryPluginAutoInstallTest( appBuildFile.appendText( // language=Groovy """ - sentry.includeProguardMapping = false + sentry { + includeProguardMapping = false + autoInstallation.enabled = true + } """.trimIndent() ) @@ -27,7 +30,6 @@ class SentryPluginAutoInstallTest( .appendArguments("--configuration") .appendArguments("debugRuntimeClasspath") .build() - println(result.output) assertTrue { "io.sentry:sentry-android:$SENTRY_SDK_VERSION" in result.output } @@ -46,7 +48,11 @@ class SentryPluginAutoInstallTest( // our plugin shouldn't install okhttp, since it's a direct dep implementation 'io.sentry:sentry-android-okhttp:5.4.0' } - sentry.includeProguardMapping = false + + sentry { + includeProguardMapping = false + autoInstallation.enabled = true + } """.trimIndent() ) @@ -83,7 +89,6 @@ class SentryPluginAutoInstallTest( .appendArguments("--configuration") .appendArguments("debugRuntimeClasspath") .build() - print(result.output) assertFalse { "io.sentry:sentry-android:$SENTRY_SDK_VERSION" in result.output } assertFalse { "io.sentry:sentry-android-timber:$SENTRY_SDK_VERSION" in result.output } assertFalse { "io.sentry:sentry-android-fragment:$SENTRY_SDK_VERSION" in result.output } @@ -102,6 +107,7 @@ class SentryPluginAutoInstallTest( implementation 'io.sentry:sentry-android-fragment:5.4.0' } + sentry.autoInstallation.enabled = true sentry.autoInstallation.sentryVersion = "5.1.2" sentry.includeProguardMapping = false """.trimIndent() diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginTest.kt index 3c6fa79e..f22234e1 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.gradle import io.sentry.android.gradle.extensions.InstrumentationFeature +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue @@ -244,6 +245,130 @@ class SentryPluginTest( assertTrue { debugOutput.exists() && debugOutput.length() > 0 } } + @Test + fun `includes flattened list of dependencies into the APK, excluding non-external deps`() { + appBuildFile.appendText( + // language=Groovy + """ + + dependencies { + implementation 'com.squareup.okhttp3:okhttp:3.14.9' + implementation project(':module') // multi-module project dependency + implementation ':asm-9.2' // flat jar + } + """.trimIndent() + ) + runner.appendArguments(":app:assembleDebug") + + val result = runner.build() + val deps = verifyDependenciesReportAndroid(testProjectDir.root) + assertEquals( + """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """.trimIndent(), + deps + ) + } + + @Test + fun `tracks dependency tree changed`() { + appBuildFile.appendText( + // language=Groovy + """ + + dependencies { + implementation 'com.squareup.okhttp3:okhttp:3.14.9' + } + """.trimIndent() + ) + runner.appendArguments(":app:assembleDebug") + + runner.build() + val deps = verifyDependenciesReportAndroid(testProjectDir.root) + assertEquals( + """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """.trimIndent(), + deps + ) + + appBuildFile.appendText( + // language=Groovy + """ + + dependencies { + implementation 'com.jakewharton.timber:timber:5.0.1' + } + """.trimIndent() + ) + runner.build() + val depsAfterChange = verifyDependenciesReportAndroid(testProjectDir.root) + assertEquals( + """ + com.jakewharton.timber:timber:5.0.1 + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + org.jetbrains.kotlin:kotlin-stdlib-common:1.5.21 + org.jetbrains.kotlin:kotlin-stdlib:1.5.21 + org.jetbrains:annotations:20.1.0 + """.trimIndent(), + depsAfterChange + ) + } + + @Test + fun `when disabled, skips the task and does not include dependencies report in the APK`() { + appBuildFile.appendText( + // language=Groovy + """ + + dependencies { + implementation 'com.squareup.okhttp3:okhttp:3.14.9' + } + + sentry.includeDependenciesReport = false + """.trimIndent() + ) + val output = runner + .appendArguments(":app:assembleDebug") + .build() + .output + + assertTrue { "> Task :app:collectExternalDebugDependenciesForSentry SKIPPED" in output } + assertThrows(AssertionError::class.java) { + verifyDependenciesReportAndroid(testProjectDir.root) + } + } + + @Test + fun `works for pure java modules`() { + moduleBuildFile.writeText( + // language=Groovy + """ + plugins { + id 'java' + id 'io.sentry.android.gradle' + } + + dependencies { + implementation 'com.squareup.okhttp3:okhttp:3.14.9' + } + """.trimIndent() + ) + + runner.appendArguments(":module:jar").build() + val deps = verifyDependenciesReportJava(testProjectDir.root) + assertEquals( + """ + com.squareup.okhttp3:okhttp:3.14.9 + com.squareup.okio:okio:1.17.2 + """.trimIndent(), + deps + ) + } + private fun applyUploadNativeSymbols() { appBuildFile.appendText( // language=Groovy diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryTaskProviderTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryTaskProviderTest.kt index 2e3be1fd..64d07f3c 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryTaskProviderTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryTaskProviderTest.kt @@ -3,10 +3,13 @@ package io.sentry.android.gradle import com.android.build.gradle.AppExtension import io.sentry.android.gradle.SentryTasksProvider.getAssembleTaskProvider import io.sentry.android.gradle.SentryTasksProvider.getBundleTask +import io.sentry.android.gradle.SentryTasksProvider.getLintVitalAnalyzeProvider +import io.sentry.android.gradle.SentryTasksProvider.getLintVitalReportProvider import io.sentry.android.gradle.SentryTasksProvider.getMergeAssetsProvider import io.sentry.android.gradle.SentryTasksProvider.getPackageBundleTask import io.sentry.android.gradle.SentryTasksProvider.getPackageProvider import io.sentry.android.gradle.SentryTasksProvider.getPreBundleTask +import io.sentry.android.gradle.SentryTasksProvider.getProcessResourcesProvider import io.sentry.android.gradle.SentryTasksProvider.getTransformerTask import kotlin.test.assertEquals import kotlin.test.assertNull @@ -185,6 +188,48 @@ class SentryTaskProviderTest { } } + @Test + fun `getLintVitalAnalyze returns null for missing task`() { + val project = ProjectBuilder.builder().build() + + assertNull(getLintVitalAnalyzeProvider(project, "debug")?.get()) + } + + @Test + fun `getLintVitalAnalyze returns correct task`() { + val (project, task) = getTestProjectWithTask("lintVitalAnalyzeDebug") + + assertEquals(task, getLintVitalAnalyzeProvider(project, "debug")?.get()) + } + + @Test + fun `getLintVitalReport returns null for missing task`() { + val project = ProjectBuilder.builder().build() + + assertNull(getLintVitalReportProvider(project, "debug")?.get()) + } + + @Test + fun `getLintVitalReport returns correct task`() { + val (project, task) = getTestProjectWithTask("lintVitalReportDebug") + + assertEquals(task, getLintVitalReportProvider(project, "debug")?.get()) + } + + @Test + fun `getProcessResources returns null for missing task`() { + val project = ProjectBuilder.builder().build() + + assertNull(getProcessResourcesProvider(project)?.get()) + } + + @Test + fun `getProcessResources returns correct task`() { + val (project, task) = getTestProjectWithTask("processResources") + + assertEquals(task, getProcessResourcesProvider(project)?.get()) + } + private fun getAndroidExtFromProject(): AppExtension { val project = ProjectBuilder.builder().build() project.plugins.apply("com.android.application") diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt index 93ae4607..d65655bf 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/TestUtils.kt @@ -33,6 +33,27 @@ internal fun verifyProguardUuid( return UUID.fromString(matcher.groupValues[1]) } +internal fun verifyDependenciesReportAndroid( + rootFile: File, + variant: String = "debug" +): String { + val apk = rootFile.resolve("app/build/outputs/apk/$variant/app-$variant.apk") + val dependenciesReport = extractZip(apk, "assets/sentry-external-modules.txt") + + assertTrue("Dependencies file is missing from the APK") { dependenciesReport.isNotBlank() } + return dependenciesReport +} + +internal fun verifyDependenciesReportJava( + rootFile: File +): String { + val apk = rootFile.resolve("module/build/libs/module.jar") + val dependenciesReport = extractZip(apk, "sentry-external-modules.txt") + + assertTrue("Dependencies file is missing from the APK") { dependenciesReport.isNotBlank() } + return dependenciesReport +} + private fun extractZip(zipFile: File, fileToExtract: String): String { ZipInputStream(FileInputStream(zipFile)).use { zis -> var entry = zis.nextEntry diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryExternalDependenciesReportTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryExternalDependenciesReportTaskTest.kt new file mode 100644 index 00000000..7ad5f774 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryExternalDependenciesReportTaskTest.kt @@ -0,0 +1,148 @@ +package io.sentry.android.gradle.tasks + +import java.io.File +import kotlin.test.assertEquals +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SentryExternalDependenciesReportTaskTest { + + @get:Rule + val tempDir = TemporaryFolder() + + @Test + fun `flattens transitive dependencies into a single sorted list`() { + val project = createRegularProject() + val output = tempDir.newFile("deps.txt") + + val task: TaskProvider = project.tasks.register( + "testDependenciesReport", + SentryExternalDependenciesReportTask::class.java + ) { + it.includeReport.set(true) + it.setRuntimeConfiguration(project.configurations.getByName("runtimeClasspath")) + it.output.set(project.layout.file(project.provider { output })) + } + + task.get().action() + + output.verifyContents() + } + + @Test + fun `skips flat jars`() { + val project = createProjectWithFlatJars() + val output = tempDir.newFile("deps.txt") + + val task: TaskProvider = project.tasks.register( + "testDependenciesReport", + SentryExternalDependenciesReportTask::class.java + ) { + it.includeReport.set(true) + it.setRuntimeConfiguration(project.configurations.getByName("runtimeClasspath")) + it.output.set(project.layout.file(project.provider { output })) + } + + task.get().action() + + assertEquals("", output.readText()) + } + + @Test + fun `skips local modules and projects`() { + val project = createMultiModuleProject() + val output = tempDir.newFile("deps.txt") + + val task: TaskProvider = project.tasks.register( + "testDependenciesReport", + SentryExternalDependenciesReportTask::class.java + ) { + it.includeReport.set(true) + it.setRuntimeConfiguration(project.configurations.getByName("runtimeClasspath")) + it.output.set(project.layout.file(project.provider { output })) + } + + task.get().action() + + assertEquals("", output.readText()) + } + + private fun File.verifyContents() { + assertEquals( + """ + androidx.annotation:annotation:1.1.0 + androidx.arch.core:core-common:2.1.0 + androidx.collection:collection:1.0.0 + androidx.core:core:1.3.2 + androidx.lifecycle:lifecycle-common-java8:2.2.0 + androidx.lifecycle:lifecycle-common:2.2.0 + androidx.lifecycle:lifecycle-process:2.2.0 + androidx.lifecycle:lifecycle-runtime:2.2.0 + androidx.versionedparcelable:versionedparcelable:1.1.0 + io.sentry:sentry-android-core:6.5.0 + io.sentry:sentry:6.5.0 + """.trimIndent(), + readText() + ) + } + + private fun createRegularProject(): Project { + with(ProjectBuilder.builder().build()) { + plugins.apply("java") + plugins.apply("io.sentry.android.gradle") + + repositories.mavenCentral() + repositories.google() + + dependencies.add("implementation", "androidx.activity:activity:1.2.0") + dependencies.add("implementation", "io.sentry:sentry-android-core:6.5.0") + return this + } + } + + private fun createProjectWithFlatJars(): Project { + with(ProjectBuilder.builder().build()) { + plugins.apply("java") + plugins.apply("io.sentry.android.gradle") + + mkdir("libs") + file("libs/local.jar").apply { createNewFile() } + + repositories.flatDir { it.dir("libs") } + + dependencies.add("implementation", ":local") + return this + } + } + + private fun createMultiModuleProject(): Project { + with(ProjectBuilder.builder().build()) { + mkdir("module") + val module = ProjectBuilder.builder() + .withName("module") + .withProjectDir(file("module")) + .withParent(this) + .build() + + mkdir("app") + val app = ProjectBuilder.builder() + .withName("app") + .withProjectDir(file("app")) + .withParent(this) + .build() + app.plugins.apply("java") + app.plugins.apply("io.sentry.android.gradle") + + app.dependencies.add( + "implementation", + app.dependencies.project(mapOf("path" to ":module")) + ) + + return app + } + } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryGenerateProguardUuidTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryGenerateProguardUuidTaskTest.kt index 7d1c71f0..2070a9d4 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryGenerateProguardUuidTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryGenerateProguardUuidTaskTest.kt @@ -1,7 +1,6 @@ package io.sentry.android.gradle.tasks import java.io.File -import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertTrue import org.gradle.api.Project @@ -11,23 +10,6 @@ import org.junit.Test class SentryGenerateProguardUuidTaskTest { - @Test - fun `generate proguard UUID sets the output file correctly`() { - val project = createProject() - val task: TaskProvider = - project.tasks.register( - "testGenerateProguardUuid", - SentryGenerateProguardUuidTask::class.java - ) { - it.outputDirectory.set(project.file("dummy/folder/")) - } - - assertEquals( - project.file("dummy/folder/sentry-debug-meta.properties"), - task.get().outputFile.get().asFile - ) - } - @Test fun `generate proguard UUID generates the UUID correctly`() { val project = createProject() @@ -36,7 +18,7 @@ class SentryGenerateProguardUuidTaskTest { "testGenerateProguardUuid", SentryGenerateProguardUuidTask::class.java ) { - it.outputDirectory.set(project.file("dummy/folder/")) + it.output.set(project.file("dummy/folder/sentry-debug-meta.properties")) } task.get().generateProperties() @@ -54,7 +36,7 @@ class SentryGenerateProguardUuidTaskTest { "testGenerateProguardUuid", SentryGenerateProguardUuidTask::class.java ) { - it.outputDirectory.set(project.file("dummy/folder/")) + it.output.set(project.file("dummy/folder/sentry-debug-meta.properties")) } val expectedFile = File(project.projectDir, "dummy/folder/sentry-debug-meta.properties") diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryUploadProguardMappingTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryUploadProguardMappingTaskTest.kt index cc86796e..7c204449 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryUploadProguardMappingTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/tasks/SentryUploadProguardMappingTaskTest.kt @@ -1,7 +1,6 @@ package io.sentry.android.gradle.tasks import java.io.File -import java.lang.IllegalStateException import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -9,6 +8,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.gradle.api.Project import org.gradle.api.file.FileCollection +import org.gradle.api.file.RegularFile import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider import org.gradle.testfixtures.ProjectBuilder @@ -25,9 +25,9 @@ class SentryUploadProguardMappingTaskTest { @Test fun `cli-executable is set correctly`() { val randomUuid = UUID.randomUUID() - createFakeUuid(randomUuid) - val project = createProject() + val uuidFileProvider = createFakeUuid(project, randomUuid) + val mappingFile = createMappingFileProvider(project, "dummy/folder/mapping.txt") val task: TaskProvider = project.tasks.register( @@ -35,7 +35,7 @@ class SentryUploadProguardMappingTaskTest { SentryUploadProguardMappingsTask::class.java ) { it.cliExecutable.set("sentry-cli") - it.uuidDirectory.set(tempDir.root) + it.uuidFile.set(uuidFileProvider) it.mappingsFiles = mappingFile it.autoUploadProguardMapping.set(true) } @@ -53,9 +53,9 @@ class SentryUploadProguardMappingTaskTest { @Test fun `with multiple mappingFiles picks the first existing file`() { val randomUuid = UUID.randomUUID() - createFakeUuid(randomUuid) - val project = createProject() + val uuidFileProvider = createFakeUuid(project, randomUuid) + val mappingFiles = createMappingFileProvider( project, "dummy/folder/missing-mapping.txt", @@ -72,7 +72,7 @@ class SentryUploadProguardMappingTaskTest { SentryUploadProguardMappingsTask::class.java ) { it.cliExecutable.set("sentry-cli") - it.uuidDirectory.set(tempDir.root) + it.uuidFile.set(uuidFileProvider) it.mappingsFiles = mappingFiles it.autoUploadProguardMapping.set(true) } @@ -84,8 +84,9 @@ class SentryUploadProguardMappingTaskTest { @Test fun `--auto-upload is set correctly`() { - createFakeUuid() val project = createProject() + val uuidFileProvider = createFakeUuid(project) + val mappingFile = createMappingFileProvider(project, "dummy/folder/mapping.txt") val task: TaskProvider = project.tasks.register( @@ -93,7 +94,7 @@ class SentryUploadProguardMappingTaskTest { SentryUploadProguardMappingsTask::class.java ) { it.cliExecutable.set("sentry-cli") - it.uuidDirectory.set(tempDir.root) + it.uuidFile.set(uuidFileProvider) it.mappingsFiles = mappingFile it.autoUploadProguardMapping.set(false) } @@ -139,8 +140,9 @@ class SentryUploadProguardMappingTaskTest { @Test fun `with sentryOrganization adds --org`() { - createFakeUuid() val project = createProject() + val uuidFileProvider = createFakeUuid(project) + val mappingFile = createMappingFileProvider(project, "dummy/folder/mapping.txt") val task: TaskProvider = project.tasks.register( @@ -148,7 +150,7 @@ class SentryUploadProguardMappingTaskTest { SentryUploadProguardMappingsTask::class.java ) { it.cliExecutable.set("sentry-cli") - it.uuidDirectory.set(tempDir.root) + it.uuidFile.set(uuidFileProvider) it.mappingsFiles = mappingFile it.autoUploadProguardMapping.set(false) it.sentryOrganization.set("dummy-org") @@ -162,8 +164,9 @@ class SentryUploadProguardMappingTaskTest { @Test fun `with sentryProject adds --project`() { - createFakeUuid() val project = createProject() + val uuidFileProvider = createFakeUuid(project) + val mappingFile = createMappingFileProvider(project, "dummy/folder/mapping.txt") val task: TaskProvider = project.tasks.register( @@ -171,7 +174,7 @@ class SentryUploadProguardMappingTaskTest { SentryUploadProguardMappingsTask::class.java ) { it.cliExecutable.set("sentry-cli") - it.uuidDirectory.set(tempDir.root) + it.uuidFile.set(uuidFileProvider) it.mappingsFiles = mappingFile it.autoUploadProguardMapping.set(false) it.sentryProject.set("dummy-proj") @@ -228,10 +231,14 @@ class SentryUploadProguardMappingTaskTest { } } - private fun createFakeUuid(uuid: UUID = UUID.randomUUID()) { - tempDir.newFile("sentry-debug-meta.properties").writeText( - "io.sentry.ProguardUuids=$uuid" - ) + private fun createFakeUuid( + project: Project, + uuid: UUID = UUID.randomUUID() + ): Provider { + val file = tempDir.newFile("sentry-debug-meta.properties").apply { + writeText("io.sentry.ProguardUuids=$uuid") + } + return project.layout.file(project.provider { file }) } private fun createMappingFileProvider( diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryPluginUtilsTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryPluginUtilsTest.kt index 097ebe56..232d0d72 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryPluginUtilsTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/util/SentryPluginUtilsTest.kt @@ -3,13 +3,21 @@ package io.sentry.android.gradle.util import io.sentry.android.gradle.testutil.createTestAndroidProject import io.sentry.android.gradle.testutil.createTestProguardProject import io.sentry.android.gradle.util.SentryPluginUtils.capitalizeUS +import io.sentry.android.gradle.util.SentryPluginUtils.getAndDeleteFile import io.sentry.android.gradle.util.SentryPluginUtils.isMinificationEnabled import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import proguard.gradle.plugin.android.dsl.ProGuardAndroidExtension class SentryPluginUtilsTest { + @get:Rule + val tempDir = TemporaryFolder() + @Test fun `capitalizes string first letter uppercase`() { assertEquals("Kotlin", "kotlin".capitalizeUS()) @@ -75,4 +83,15 @@ class SentryPluginUtilsTest { assertEquals(false, isMinificationEnabled(project, debug)) } + + @Test + fun `getAndDelete deletes the file`() { + val (project, _) = createTestAndroidProject() + val file = tempDir.newFile("temp-file.txt") + + assertTrue { file.exists() } + + getAndDeleteFile(project.layout.file(project.provider { file })) + assertFalse { file.exists() } + } } diff --git a/plugin-build/src/test/resources/testFixtures/appTestProject/app/build.gradle b/plugin-build/src/test/resources/testFixtures/appTestProject/app/build.gradle index 15571ee0..0c31f223 100644 --- a/plugin-build/src/test/resources/testFixtures/appTestProject/app/build.gradle +++ b/plugin-build/src/test/resources/testFixtures/appTestProject/app/build.gradle @@ -3,10 +3,19 @@ plugins { id "io.sentry.android.gradle" } +repositories { + flatDir { + dir('../libs') + } +} + android { namespace 'com.example' } sentry { autoUploadProguardMapping = false + autoInstallation { + enabled = false + } } diff --git a/plugin-build/src/test/resources/testFixtures/appTestProject/libs/asm-9.2.jar b/plugin-build/src/test/resources/testFixtures/appTestProject/libs/asm-9.2.jar new file mode 100644 index 00000000..3557ae41 Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/appTestProject/libs/asm-9.2.jar differ diff --git a/plugin-build/src/test/resources/testFixtures/appTestProject/module/build.gradle b/plugin-build/src/test/resources/testFixtures/appTestProject/module/build.gradle new file mode 100644 index 00000000..075ba3d5 --- /dev/null +++ b/plugin-build/src/test/resources/testFixtures/appTestProject/module/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'java' +} diff --git a/plugin-build/src/test/resources/testFixtures/appTestProject/settings.gradle b/plugin-build/src/test/resources/testFixtures/appTestProject/settings.gradle index 9d495b34..35f88766 100644 --- a/plugin-build/src/test/resources/testFixtures/appTestProject/settings.gradle +++ b/plugin-build/src/test/resources/testFixtures/appTestProject/settings.gradle @@ -1 +1 @@ -include ':app' \ No newline at end of file +include ':app', ':module' diff --git a/settings.gradle.kts b/settings.gradle.kts index 19b78c67..fce57a13 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include(":examples:android-guardsquare-proguard") include(":examples:android-ndk") include(":examples:android-instrumentation-sample") include(":examples:android-room-lib") +include(":examples:spring-boot-sample") includeBuild("plugin-build") { dependencySubstitution { substitute(module("io.sentry:sentry-android-gradle-plugin")).using(project(":"))