Skip to content

Latest commit

 

History

History
2098 lines (1584 loc) · 86.3 KB

how_to_work_with_gradle.md

File metadata and controls

2098 lines (1584 loc) · 86.3 KB

How to Work with Gradle

This guide covers Gradle best practices, how we apply them in this repository, and the constraints that currently require exceptions. Understanding these concepts will help you navigate the build system and contribute effectively.

The guide describes shared practices and repository constraints. Details specific to one plugin or task belong beside its source or in local documentation.

Quick Reference

Core Concepts:

  • Gradle skills - Installing and using the required skill for agent-assisted changes
  • Gradle Files — Groovy vs Kotlin DSL
  • Build Lifecycle — Initialization → Configuration → Execution phases
  • Tasks — Lifecycle tasks, inputs/outputs, lazy configuration
  • Configurations — Understadning their relations and their role; api vs implementation
  • Dependencies — Use single GAV strings, exclusions, resolution strategies, locking
  • Convention Plugins — Project and settings plugins in our build logic buildSrc/
  • Lazy API — Use named(), register(), configureEach() for performance

dd-trace-java Specifics:

  • Custom Extensions — testJvmConstraints, tracerJava, CI slots, git change tracking
  • Script Plugins — Standalone .gradle files (deprecated, ongoing effort migrate those to convention plugins)
  • Our Daemon JVM — Configuring the JVM that runs Gradle

Troubleshooting:

Tip

First time with Gradle? Start with Gradle Files, Build Lifecycle, and Tasks.

Gradle skills

Gradle publishes a best-practices skill for reviewing build scripts and plugin code. In this repository, agent-assisted Gradle code changes require an audit of the affected code and its consuming modules with this skill. The requested task defines the scope of fixes; unrelated findings are reported separately.

The upstream installation instructions cover supported agents. A complete installation includes the skill's reference files. Without them, only read-only investigation is available until installation is complete.

The skill's catalog may describe newer or nightly APIs. Its recommendations need to match our Gradle wrapper version and the JVMs that run the affected code.

What are Gradle Files?

Gradle builds are defined through a set of build scripts. These scripts can be written in two Domain Specific Languages (DSLs): Groovy DSL (*.gradle) and Kotlin DSL (*.gradle.kts) .

Groovy DSL

The original Gradle DSL uses Groovy syntax. Files use the .gradle extension.

Avoid Groovy DSL if you can, small project, however since the project still rely heavily on script plugins written in Groovy, this is not always straightforward to use Kotlin DSL.

plugins {
    id 'java'
}

dependencies {
    implementation 'com.google.guava:guava:32.1.2-jre'
}

tasks.register('hello') {
    doLast {
        println 'Hello from Groovy DSL'
    }
}

Note

Ideally, prefer the Kotlin DSL approach as it has better IDE support. However, due to the script plugins this is not always possible in an easy way.

Kotlin DSL

The Kotlin DSL offers type-safety, better IDE support, and compile-time checking. Files use the .gradle.kts extension.

plugins {
    id("java")
}

dependencies {
    implementation("com.google.guava:guava:32.1.2-jre")
}

tasks.register("hello") {
    doLast {
        println("Hello from Kotlin DSL")
    }
}

Key differences at a glance:

Aspect Groovy DSL Kotlin DSL
File extension .gradle .gradle.kts
String quotes Single ' or double " Double " only
Method calls Parentheses optional Parentheses required
Property assignment = optional = required (mostly)
IDE support Limited Full auto-completion and refactoring
Type safety Dynamic typing Static typing with compile-time checks

Gradle Build Lifecycle

Gradle executes builds in distinct phases. Understanding this lifecycle is essential for writing correct and efficient build logic.

1. Initialization Phase

Gradle runs init scripts, then evaluates the settings script to determine which projects belong to the build.

  • Init scripts can configure a build without changing its build scripts. They can be passed with --init-script or loaded from the Gradle user home's init.d/ directory (~/.gradle/init.d/ by default).

    For example, an init script can enable offline dependency resolution:

    // offline.init.gradle.kts
    gradle.startParameter.isOffline = true
    ./gradlew --init-script offline.init.gradle.kts build

    This invocation uses cached dependencies and fails if a required dependency is missing.

  • settings.gradle.kts defines the root project and includes subprojects.

    The settings script defines the project structure:

    // settings.gradle.kts
    rootProject.name = "my-project"
    
    include(":module-a")
    include(":module-b")
    include(":module-c:submodule")

2. Configuration Phase

Gradle evaluates all build scripts of the participating projects. During this phase:

  • Build scripts (build.gradle.kts) are executed
  • Tasks are registered and configured
  • The task graph is constructed based on dependencies

Note

Code in the configuration phase runs on every build invocation, even if the requested task doesn't need it. Keep configuration-time logic fast and avoid I/O operations.

// build.gradle.kts
plugins {
    id("java")
}

// This runs during CONFIGURATION - avoid expensive operations in this phase
val expensiveValue = file("some-file.txt").readText() // Bad!

tasks.register("myTask") {
    // Task configuration also runs during configuration phase
    // But the task ACTION (doLast/doFirst) runs during execution
    doLast {
        // This runs during EXECUTION phase
        println("Executing myTask")
    }
}

3. Execution Phase

Gradle executes the selected tasks in dependency order. Only tasks required to complete the requested goal are executed.

./gradlew build

> Task :compileJava
> Task :processResources
> Task :classes
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :testClasses
> Task :test
> Task :check
> Task :build

Build Logic Location

In a well-organized Gradle project, build logic lives in specific places:

Location Purpose
settings.gradle.kts Project structure, repositories and plugin management
build.gradle.kts Configuration for one project
buildSrc/ Legacy build logic, automatically available to the main build
build-logic/ Build logic included explicitly through plugin management
gradle/ Version catalogs, wrapper files and remaining script plugins

buildSrc and included builds have their own settings and dependencies. Repositories and version catalogs from the main build are not inherited, so each build has its own declarations or explicit imports.

Gradle recommends an included build-logic build for shared conventions. Its plugins have explicit dependencies and are added only to the projects that use them. This limits classpath coupling and can reduce build script recompilation when build logic changes. Plugins can also be built and tested independently.

buildSrc is the legacy location for our build logic and remains supported by Gradle.

Project build scripts and applied script plugins load buildSrc classes through a common parent classloader. We are replacing script plugins with convention plugins, using buildSrc as an intermediate step for conventions applied across all modules through existing scripts. Those scripts still need access to shared classes through that classloader, which currently prevents those classes from moving into build-logic. Independent conventions applied on demand can go directly into build-logic: explicit plugin requests provide their classpath to the consuming projects.

Caution

Script plugins are not recommended. Shared build logic belongs in convention plugins or binary plugins.

Running Build Logic Tests

Unit tests cover helpers, while Gradle's TestKit covers behaviour that depends on Gradle. Simple declarative conventions may not need dedicated tests. Larger plugins with conditional behaviour benefit from functional tests covering:

  • Plugin application, including different application orders for interacting plugins
  • Configuration roles and dependency resolution
  • Task wiring, outcomes and generated files
  • Behaviour in a consuming module

Existing fixtures provide the setup for these tests. Isolated Projects support is not currently required, but avoiding cross-project state reduces future migration work. See Gradle's plugin testing guide.

Repeated runs exercise different behaviour:

  • --rerun on a task, or --rerun-tasks for all selected tasks, forces execution again.
  • Two identical runs with --configuration-cache show whether the second reuses the entry.
  • Unchanged inputs leave an incremental task UP-TO-DATE; changed inputs update its output.
  • Restoring outputs from a populated build cache into a clean fixture produces FROM-CACHE.

Running buildSrc/ tests within IntelliJ IDEA works without special treatment because the build detects the injected idea.active system property.

However, via the command line, buildSrc/ tests are disabled unless opted in with -PrunBuildSrcTests:

./gradlew -p buildSrc :test -PrunBuildSrcTests            # whole suite
./gradlew -p buildSrc :test -PrunBuildSrcTests --tests '*MuzzlePluginFunctionalTest'

Repository proxies configured through MAVEN_REPOSITORY_PROXY or GRADLE_PLUGIN_PROXY are propagated to TestKit builds through repository-proxy.init.gradle.kts.

How Gradle Compiles Build Scripts

During the Configuration phase, Gradle doesn't simply execute build scripts top-to-bottom. Instead, it first extracts and processes certain special blocks before compiling the rest of the script. This is necessary because Gradle needs to know which plugins to apply before it can understand the DSL extensions they provide.

Processing order for settings.gradle.kts (Initialization phase):

  1. pluginManagement {} — Configures plugin repositories and version resolution. If present it must be the first block.
  2. plugins {} — Declares the settings plugins to apply.
  3. Script body — Project includes, build configuration, etc.

Processing order for build.gradle.kts (Configuration phase):

  1. buildscript {} — Declares dependencies for the build script itself (the script's classpath). It should be avoided now with the plugins {} block. If present it must be the first block.
  2. plugins {} — Declares plugins to apply. Gradle extracts this block first to load plugin classes before compiling the rest.
  3. Script body — The rest of the script is compiled and executed, now with access to DSL extensions from applied plugins.

Gradle Tasks

A task usually represents an independent unit of work, however there are also lifecycle tasks. A lifecycle task is a task that doesn't perform work itself but aggregates other tasks. It provides convenient entry points for common build operations.

The base plugin applies the lifecycle-base plugin (org.gradle.language.base.plugins.LifecycleBasePlugin), which defines these standard lifecycle tasks:

Task Purpose
clean Deletes the build directory
assemble Assembles all outputs (e.g., JARs) without running tests
check Runs all verification tasks (tests, linting, etc.)
verification Base task for all verification tasks (check depends on it)
build Performs a full build (assemble + check)

Tip

Use ./gradlew tasks to list available tasks, or ./gradlew tasks --all to include tasks from all subprojects.

Task Inputs and Outputs

Gradle tasks declare inputs (what they read) and outputs (what they produce). This metadata enables two key optimizations:

  • Incremental builds: If inputs haven't changed, the task is UP-TO-DATE and skipped
  • Build caching: Outputs can be stored and retrieved (FROM-CACHE) across builds

Important

A cacheable task must declare every input that can affect its outputs.

Choose inputs that preserve correctness while avoiding unnecessary invalidation. Overly broad inputs reduce cache reuse; missing inputs can cause Gradle to reuse results produced under different conditions.

External state used by test or build code must also be tracked. For example, resolve Docker image tags to immutable digests and declare those digests as task inputs before Gradle checks for cached results.

Secrets in cached outputs

Gradle caches declared task outputs, including test reports and captured stdout/stderr. Input fingerprints and CI log masking do not sanitize those files.

Keep credentials used for authentication or in-memory signing out of generated resources, archives, and reports. Unset credentials before subsequent tasks that do not need them.

Declaring Inputs and Outputs

In custom task class

In custom tasks, use annotations to declare inputs and outputs:

Annotation Purpose
@Input A simple value (String, Boolean, etc.)
@InputFile A single input file
@InputFiles Multiple input files
@InputDirectory An input directory
@OutputFile A single output file
@OutputDirectory An output directory
@Internal Excluded from up-to-date checks
@Nested A nested object with its own input/output annotations

More annotations are documented here.

Each task owns its outputs, and path sensitivity reflects which parts of an input path affect the result. A custom task class can opt into build caching with @CacheableTask when its outputs are reproducible from its declared inputs. @DisableCachingByDefault records why caching is unsuitable otherwise.

In custom tasks declared with DSL

For ad-hoc tasks or when you can't use annotations, declare inputs and outputs programmatically via the inputs and outputs properties:

tasks.register("processData") {
    // Declare inputs
    inputs.property("version", project.version)
    inputs.file("config.json")
    inputs.files(fileTree("src/data"))
    inputs.dir("templates")

    // Declare outputs
    outputs.file(project.layout.buildDirectory.file("output.txt"))
    outputs.dir(project.layout.buildDirectory.dir("generated"))

    // Cacheability (required for build cache)
    outputs.cacheIf { true }

    doLast {
        // Task action
    }
}
Method Purpose
inputs.property(name, value) A named input value
inputs.file(path) A single input file
inputs.files(paths) Multiple input files
inputs.dir(path) An input directory
destroyables.register(paths) Paths that will be deleted
outputs.file(path) A single output file
outputs.dir(path) An output directory
outputs.cacheIf { } Enable build caching conditionally

Note

Prefer annotations in custom task classes for better type safety and documentation. Use the programmatic API for ad-hoc tasks registered with tasks.register.

Test Container Image Inputs

The dd-trace-java.testcontainers convention can be used in modules whose tests need to start containers. Image tags are not stable, they can be modified, and resolve to a different images ; this needs to be captured in the inputs to invalidate the cached results for this build.

This plugin allows to declare them in the dependencies block as testContainerImage(image(...)). The actual image digest will be resolved before Gradle computes the input fingerprints to look up the cached result.

CI Builder Image Inputs

The dd-trace-java.ci-builder-inputs convention can be used when a module's cacheable tasks depend on specifics of the CI image (native toolchain, runtime libraries, tool-specific output). Invoking common utilities such as cat, echo, ls or sleep alone does not justify applying the convention plugin, which would needlessly invalidate cached results on every image change.

Lazy Configuration with Providers

Gradle uses lazy configuration to defer value resolution until needed. Instead of setting values directly, you use Property and Provider types:

abstract class MyTask : DefaultTask() {
    @get:Input
    abstract val message: Property<String>  // Lazy, mutable

    @get:InputFile
    abstract val inputFile: RegularFileProperty

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty
}

Common property types:

  • Property<T> — single value
  • ListProperty<T> — ordered collection
  • SetProperty<T> — unique values
  • MapProperty<K, V> — key-value pairs
  • RegularFileProperty / DirectoryProperty — file system locations

Note

Lazy properties avoid configuration-time overhead and ensure values are resolved in the correct order during the build.

Task Outcome Labels

When running Gradle tasks, you'll see status labels indicating what happened during execution, for example with --console=verbose:

Label Description
(no label) The task ran and executed its actions
UP-TO-DATE The task's outputs are current; no work needed
FROM-CACHE Outputs were retrieved from the build cache
SKIPPED The task was excluded (e.g., via -x or onlyIf condition)
NO-SOURCE The task had no input files to process

Task Dependencies

Gradle infers a task dependency when a producing task's output provider is connected to a consuming task's input. The declared inputs and outputs also support up-to-date checks; see Using Providers.

An explicit dependsOn schedules a prerequisite even when no artifact flows between tasks. Lifecycle tasks such as check and build use it to collect other tasks.

mustRunAfter and shouldRunAfter only order tasks already selected to run. They do not schedule a prerequisite. finalizedBy schedules follow-up work such as cleanup, including when that task fails. See Controlling Task Execution.

Avoid usage of TaskDependency interface.

Tasks tips

  • Prefer Sync over Copy: Sync mirrors the source to the destination by removing files that no longer exist in the source. Copy leaves stale files behind, which can cause subtle bugs with old configs, renamed classes, or deleted resources.

  • Prefer Delete task over delete(): The Delete task type properly declares what it destroys, allowing Gradle to order tasks correctly, preventing deleting files that other tasks need.

    // ❌ Ad-hoc deletion
    tasks.register("cleanGenerated") {
        doLast {
            delete(layout.buildDirectory.dir("generated")) // Runs immediately when task executes
        }
    }
    
    // ✅ Proper Delete task
    tasks.register<Delete>("cleanGenerated") {
        delete(layout.buildDirectory.dir("generated"))
    }

Gradle Configurations

Configurations are a fundamental concept in Gradle's dependency management system. Understanding them is essential for working effectively with the build.

What Are Configurations?

A configuration is a named collection of dependencies that serves a specific purpose in the build. Think of configurations as labeled buckets where you place dependencies based on how they should be used.

dependencies {
    // "implementation" is a configuration
    implementation("com.google.guava:guava:32.1.2-jre")

    // "testImplementation" is another configuration
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}

Configurations serve two main purposes:

  1. Declaring dependencies: You add dependencies to configurations to express what your project needs
  2. Resolving dependencies: Gradle uses configurations to compute the full dependency graph (including transitives)

Tip

The full flow:

  1. You declare dependencies in declarable configurations
  2. Gradle resolves them into resolvable classpaths
  3. Two usual consumers:
    • Source set (via tasks) uses resolvable classpaths to compile/run
    • Other projects consume via consumable apiElements/runtimeElements

Declarable vs Resolvable vs Consumable

Configurations have three key attributes that define their role:

Attribute Description
Declarable (canBeDeclared) Can have dependencies added to it directly in the dependencies {} block
Resolvable (canBeResolved) Can be resolved to produce a set of files (e.g., for compilation or runtime)
Consumable (canBeConsumed) Can be consumed by other projects as a dependency

Important

These three roles are mutually exclusive. Each configuration should have exactly one role enabled. A single configuration should not attempt to declare, resolve, and expose simultaneously.

Most configurations you interact with are declarable only — they're buckets where you put dependencies. Gradle then creates internal configurations that are resolvable (to get the actual files) or consumable (to expose artifacts to dependent projects).

Note

You rarely interact with resolvable or consumable configurations directly. Gradle creates and manages them automatically when you apply plugins like java or java-library.

The Java Library Plugin Configurations

When you apply the java-library plugin, Gradle creates a comprehensive set of configurations. The following diagram shows the relationships between them (based on the official Gradle documentation):

---
config:
  flowchart:
    curve: bundle
    htmlLabels: true
---
graph LR
    subgraph Declarable["bucket configurations (<b>declarable</b>)"]
        api[api]
        compileOnly[compileOnly]
        implementation[implementation]
        runtimeOnly[runtimeOnly]
    end

    subgraph Resolvable["classpaths (<b>resolvable</b>)"]
        compileClasspath[compileClasspath]
        runtimeClasspath[runtimeClasspath]
    end

    subgraph SourceSet["sourceSet"]
        main([main])
    end

    subgraph Consumable["elements (<b>consumable</b>)"]
        apiElements[apiElements]
        runtimeElements[runtimeElements]
    end

    %% API flow - exposed to consumers
    api --> apiElements
    api --> implementation

    %% Compile-time flow
    compileOnly --> compileClasspath

    %% Implementation flow - internal only
    implementation --> compileClasspath
    implementation --> runtimeClasspath
    implementation --> runtimeElements

    %% Runtime-only flow
    runtimeOnly --> runtimeClasspath
    runtimeOnly --> runtimeElements

    %% Source set uses resolvable configurations
    compileClasspath --> main
    runtimeClasspath --> main

    %% Node styling
    style api fill:#90caf9,stroke:#1976d2
    style compileOnly fill:#90caf9,stroke:#1976d2
    style implementation fill:#90caf9,stroke:#1976d2
    style runtimeOnly fill:#90caf9,stroke:#1976d2
    style compileClasspath fill:#a5d6a7,stroke:#388e3c
    style runtimeClasspath fill:#a5d6a7,stroke:#388e3c
    style main fill:#e0e0e0,stroke:#757575
    style apiElements fill:#fff9c4,stroke:#f9a825
    style runtimeElements fill:#fff9c4,stroke:#f9a825

    %% Edge styling - long dashes for consumable (indices: 0=api->apiElements, 5=impl->runtimeElements, 7=runtimeOnly->runtimeElements)
    linkStyle 0 stroke:#9e9e9e,stroke-dasharray:10 5
    linkStyle 5 stroke:#9e9e9e,stroke-dasharray:10 5
    linkStyle 7 stroke:#9e9e9e,stroke-dasharray:10 5
Loading

Legend:

Block Color Role Description
🔵 Blue Declarable Where you add dependencies (api, implementation, compileOnly, runtimeOnly)
🟢 Green Resolvable Used by tasks to get files (compileClasspath, runtimeClasspath)
🟡 Yellow Consumable Exposed to consumer projects (apiElements, runtimeElements)
⬜ Gray Tasks Gradle tasks that use the configurations
Configuration Compile Classpath Runtime Classpath Exposed to Consumers Use Case
api ✅ ✅ ✅ Types in your public API (method signatures, return types)
implementation ✅ ✅ ❌ Internal dependencies not exposed to consumers
compileOnly ✅ ❌ ❌ Provided at runtime by the environment (e.g., servlet-api)
compileOnlyApi ✅ ❌ ✅ Compile-only dependency that's part of the public API
runtimeOnly ❌ ✅ ❌ Needed only at runtime (e.g., JDBC drivers, logging backends)

Note

compileOnlyApi flows to apiElements (so consumers see it at compile time), i.e. the existing apiElements handles both api and compileOnlyApi exposure.

The api vs implementation Distinction

The key difference between api and implementation is transitive exposure to consumers:

graph LR
    subgraph "Library Project"
        api_dep[api: Guava]
        impl_dep[implementation: OkHttp]
    end

    subgraph "Consuming Project"
        consumer[depends on Library]
        compile[compileClasspath]
        runtime[runtimeClasspath]
    end

    api_dep -->|"exposed"| compile
    api_dep -->|"exposed"| runtime
    impl_dep -.->|"NOT on compile classpath"| compile
    impl_dep -->|"on runtime classpath"| runtime

    style api_dep fill:#c8e6c9
    style impl_dep fill:#ffecb3
    style compile fill:#e3f2fd
    style runtime fill:#e3f2fd
Loading
Configuration Transitive to Consumers' Compile Transitive to Consumers' Runtime When to Use
api ✅ ✅ Types appear in project's public API
implementation ❌ ✅ Project's internal implementation detail, not part of public API

Example:

// java-library plugin required for 'api'
plugins {
    `java-library`
}

dependencies {
    // Guava types appear in public method signatures → use api
    api("com.google.guava:guava:32.1.2-jre")

    // OkHttp is used internally, not exposed → use implementation
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
public class MyService {
    // Guava's ImmutableList is in the public API → needs 'api'
    public ImmutableList<String> getItems() {
        return ImmutableList.of("a", "b");
    }

    // OkHttpClient is private, only used internally → 'implementation' is fine
    private final OkHttpClient client = new OkHttpClient();
}

Tip

Prefer implementation over api when dependencies are internal. Using implementation keeps dependencies hidden from other projects, which:

  • Reduces consumers' compile classpath (faster compilation)
  • Allows changing dependencies without breaking consumers
  • Avoids version conflicts in dependent projects

Configuration Inheritance with extendsFrom

Configurations can inherit dependencies from other configurations using extendsFrom. When configuration A extends configuration B, A automatically includes all dependencies from B.

Built-in inheritance: The Java plugin automatically sets up inheritance so test code can use production dependencies:

graph LR
    subgraph Main
        implementation
    end

    subgraph Test
        testImplementation
    end

    subgraph Test Fixtures
        testFixturesImplementation
    end

    implementation --> testImplementation
    implementation --> testFixturesImplementation
    testFixturesImplementation -.->|"testFixtures(project)"| testImplementation
Loading

Custom configurations: Use extendsFrom when creating your own configurations that should inherit from existing ones:

// Create a declarable configuration for integration tests
val integrationTestImplementation by configurations.dependencyScope("integrationTestImplementation") {
    extendsFrom(configurations.named("testImplementation").get())
}

dependencies {
    // Gets all testImplementation deps automatically via inheritance
    integrationTestImplementation("org.testcontainers:testcontainers:1.19.0")
}

Test Suite Configurations

Each test suite (like test, integrationTest) gets its own set of configurations that mirror the main ones.

In this project, the gradle/test-suites.gradle script provides helpers to create test suites with proper configuration inheritance:

Helper Description
addTestSuite('name') Creates name test suite extending test, sources in src/name/
addTestSuiteForDir('name', 'dir') Creates name test suite extending test, sources in src/dir/
addTestSuiteExtendingForDir('name', 'parent', 'dir') Creates name test suite extending parent test suite, sources in src/dir/

Each of these helpers also registers a forked task for the suite with addForkedTestTask. It runs the suite's *ForkedTest classes, each in its own JVM, reusing the suite's compiled classes and runtime classpath. The task is named after the suite with its Test suffix replaced by ForkedTest, and it uses the suite's Java launcher and test JVM constraints.

For example:

// Creates 'latestDepTest' suite extending 'test', sources in src/latestDepTest/,
// and 'latestDepForkedTest' running its '*ForkedTest' classes
addTestSuite('latestDepTest')
graph LR
    subgraph Main
        implementation
    end

    subgraph test
        testImplementation
    end

    subgraph latestDepTest
        latestDepTestImplementation
    end

    implementation --> testImplementation --> latestDepTestImplementation
Loading

Similar inheritance applies to compileOnly, runtimeOnly, and annotationProcessor configurations.

Declare a separate forked suite only when forked tests need different sources or dependencies. If it takes the name of the suite's forked task, pass false as the last argument of the suite helper so that the suite doesn't register its own forked task. Passing false also keeps the suite's forked tests from running at all, which should stay exceptional and be explained in a comment.

Creating Custom Configurations

You can create configurations for special purposes. Use the factory methods to explicitly document intent and set the appropriate flags automatically:

// A resolvable configuration for tools that generate code
// Use resolvable() - we want to resolve it to get files, not expose to others
val codeGenerator by configurations.resolvable("codeGenerator")

dependencies {
    codeGenerator("com.example:my-generator:1.0")
}

// Use the resolved files in a task
tasks.register("generateCode") {
    val generatorClasspath = configurations.named("codeGenerator")
    inputs.files(generatorClasspath)
    doLast {
        // generatorClasspath.get().files contains the resolved JARs
    }
}

Factory methods for custom configurations:

Factory Method Role Use Case
resolvable(...) Resolvable Resolve to get files (e.g., tool classpaths, code generators)
consumable(...) Consumable Expose artifacts to other projects
dependencyScope(...) Declarable Bucket for declaring dependencies

Tip

While it's not always possible, prefer factory methods over manually setting isCanBeResolved/isCanBeConsumed flags. They make the configuration's purpose explicit and prevent accidental misconfiguration.

Viewing Configurations

To see all configurations and their relationships:

# List all configurations
./gradlew :my-project:dependencies

# Show a specific configuration's dependency tree
./gradlew :my-project:dependencies --configuration runtimeClasspath

# Show all resolvable configurations
./gradlew :my-project:resolvableConfigurations

Dependencies

Now that you understand configurations, let's look at how to declare and manage dependencies effectively.

Declaring Dependencies

Dependencies are declared in the dependencies {} block, specifying both the configuration and the dependency coordinates.

Single GAV String (Preferred)

Always prefer the single GAV (Group:Artifact:Version) string notation over the map-based syntax:

dependencies {
    // ❌ Avoid - map-based notation (verbose, error-prone)
    implementation(group = "net.bytebuddy", name = "byte-buddy", version = "1.18.3")

    // ✅ Preferred - single GAV string
    implementation("net.bytebuddy:byte-buddy:1.18.3")
    testImplementation("org.junit.jupiter:junit-jupiter:5.14.1")
}

It's easier to read and write, it's the standard way to communicate coordinates (and it's also the recommended way of Maven Central). Finally, IDEs and linters work better with string literals

Tip

The single GAV string is the official Gradle best practice.

Version Catalogs (Recommended for Multi-Module Projects)

For consistency across modules, use version catalogs defined in gradle/libs.versions.toml:

Important

Instrumentation modules are an exception: they declare library-specific versions directly in their build files rather than using the version catalog. This is because instrumentation modules need to test against the exact versions they instrument.

# gradle/libs.versions.toml
[versions]
byte-buddy = "1.18.3"
slf4j = "1.7.30"
junit5 = "5.14.1"

[libraries]
bytebuddy = { module = "net.bytebuddy:byte-buddy", version.ref = "byte-buddy" }
slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" }
dependencies {
    implementation(libs.bytebuddy)
    implementation(libs.slf4j)
    testImplementation(libs.junit.jupiter)
}

Dependency Exclusions

Sometimes you need to exclude transitive dependencies to avoid version conflicts or unwanted libraries.

Excluding from Specific Dependencies

Exclude a transitive dependency from a single declaration using exclude:

dependencies {
    // Exclude specific transitive dependency
    implementation("com.example:library:1.0") {
        exclude(group = "org.slf4j", module = "slf4j-api")
    }

    // Exclude all modules from a group
    implementation("com.example:another-library:2.0") {
        exclude(group = "commons-logging")
    }
}

Excluding from Configurations

Exclude a transitive dependency from all dependencies in a configuration:

configurations.named("implementation") {
    exclude(group = "org.slf4j", module = "slf4j-log4j12")
}

// Exclude from test configurations
configurations.named("testImplementation") {
    exclude(group = "junit", module = "junit")  // Exclude JUnit 4 when using JUnit 5
}

Exclude a dependency from the entire project across all configurations (use with caution):

configurations.configureEach {
    exclude(group = "commons-logging", module = "commons-logging")
}

Caution

Global exclusions affect all configurations, including those you might not expect (build scripts, plugins, tooling). This can break unexpected things:

  • Build plugins that depend on the excluded library
  • Tooling configurations (e.g., code generation, static analysis)
  • Transitive resolution in unrelated modules

Prefer configuration-specific or dependency-specific exclusions unless you have a clear understanding of the impact.

Viewing Dependency Trees with Exclusions

To verify exclusions worked as expected:

# See the resolved dependency tree
./gradlew :dd-java-agent:testing:dependencies --configuration api

# Look for lines showing exclusions applied:
#   +--- org.yaml:snakeyaml:2.0
#   |    \--- org.snakeyaml:snakeyaml-engine:2.6 -> excluded (via configuration api)

Example: Replace a dependency globally

configurations.configureEach {
    resolutionStrategy.eachDependency {
        // Replace log4j with reload4j (security fix)
        if (requested.group == "log4j" && requested.name == "log4j") {
            useTarget("ch.qos.reload4j:reload4j:${requested.version}")
            because("log4j has critical vulnerabilities")
        }
    }
}

Dependency Constraints

For multi-module projects where you want to suggest versions without directly adding dependencies:

dependencies {
    // Define constraints - these don't add dependencies themselves
    constraints {
        implementation("org.slf4j:slf4j-api:2.0.0")
    }

    // When another module pulls in slf4j-api, it will use 2.0.0
    implementation("com.example:library-that-uses-slf4j:1.0")
}

Constraints are useful for:

  • Aligning transitive dependency versions across modules
  • Platform/BOM definitions
  • Enforcing security patches without modifying every module

Best Practices

Tip

Follow the official Gradle dependency management best practices for robust builds:

  • Use single GAV strings for dependency declarations
  • Prefer version catalogs for multi-module projects to centralize versions
  • Avoid dynamic versions (1.+, latest.release) in production builds—they're non-reproducible, they are ok in instrumentation tests though
  • Use dependency constraints rather than forcing versions when possible
  • Exclude dependencies at the narrowest scope (specific dependency > configuration > global)
  • Document why you're forcing versions or excluding dependencies (use because())
  • Verify dependency trees with ./gradlew dependencies after making changes

For general Gradle best practices beyond dependencies, see the official best practices guide.

Dependency Locking

Dependency locking ensures reproducible builds by pinning exact versions of all transitive dependencies. This project uses Gradle's built-in dependency locking to:

  • Enable reproducible builds: Rebuild any version with identical dependencies
  • Prevent unexpected updates: Floating versions ([16.0,20.0], 1.+) are resolved once and locked
  • Improve IDE performance: IDEs don't re-index on every library release
  • Track dependency changes: Lock file diffs show exactly what changed

All projects have dependency locking enabled via the dd-trace-java.dependency-locking convention plugin. Lock files are stored as gradle.lockfile in each project directory.

Updating Lock Files

When adding, updating, or removing a dependency in a build script or gradle/libs.versions.toml, regenerate the lockfiles for all affected modules and include them in the same PR. Shared dependencies can affect multiple modules.

# Regenerate lockfiles for all projects
./gradlew resolveAndLockAll --write-locks

# Regenerate lockfiles for a specific project, e.g. dd-trace-api
./gradlew :dd-trace-api:resolveAndLockAll --write-locks

Note

Lock files are also automatically updated weekly by CI.

Important

This project uses lenient lock mode (LockMode.LENIENT), which permits dependency changes without failing lock validation. This prevents build failures when dependencies are temporarily unavailable or when resolution conflicts occur.

When Lock Files Are Used

Lock files apply whenever dependencies are resolved:

  • During compilation (compileJava, compileTestJava)
  • During test execution (test, latestDepTest)
  • When generating classpaths for IDE sync
  • When resolving configurations manually (./gradlew dependencies)

If a locked version can't be satisfied (e.g., due to a constraint or exclusion), lenient mode allows resolution to continue with the best available version.

Viewing Locked Dependencies

# See locked dependencies for a project
cat dd-trace-api/gradle.lockfile

# View resolved dependencies (shows which versions are actually used)
./gradlew :dd-trace-api:dependencies --configuration runtimeClasspath

Lock File Workflow

  1. Change dependencies: Add, update, or remove dependencies in build scripts or the version catalog
  2. Regenerate lockfiles: Run resolveAndLockAll --write-locks for all affected projects
  3. Include lockfiles: Review the generated changes and include affected gradle.lockfile files in the same PR
  4. CI builds: Builds read the updated lockfiles; lenient mode does not enforce lockfile consistency
  5. Weekly CI job: Refreshes floating versions independently of dependency PRs

Note

Instrumentation modules often use version ranges (e.g., [3.0,3.12.12]) to test against multiple library versions. Dependency locking pins these ranges to specific versions in lock files, ensuring consistent test behavior.

Useful dd-trace-java Extensions

This project provides several custom Gradle extensions to manage multi-JVM testing, multi-version source sets, and CI optimizations.

testJvmConstraints Extension

Controls which JVM versions are allowed to run tests. Applied via the dd-trace-java.test-jvm-constraints plugin.

Each Test task also tracks its selected JVM's vendor, full runtime version, VM version, operating system, and architecture as inputs. Changing these values invalidates up-to-date checks and build-cache entries, even when the Java major version stays the same.

plugins {
    id("dd-trace-java.test-jvm-constraints")
}

// project-wide constraints (apply to all Test tasks by default)
testJvmConstraints {
    minJavaVersion = JavaVersion.VERSION_11
    maxJavaVersion = JavaVersion.VERSION_21
    excludeJdk.add("IBM8")
    allowReflectiveAccessToJdk = true
}

// task-specific constraints (override project defaults for this task)
tasks.named<Test>("latestDepTest") {
    testJvmConstraints {
        minJavaVersion = JavaVersion.VERSION_17  // requires Java 17+ for this test suite
    }
}
Property Rule
minJavaVersion, maxJavaVersion Inclusive bounds. The check rejects an empty range unless a forced JDK remains allowed.
forceJdk Overrides version bounds; inclusion and exclusion still apply.
includeJdk Restricts JDK names. The check fails when no entry is allowed. Set it on the test task: a project-level value is ignored.
excludeJdk Rejects listed JDK names, including forced JDKs.
allowReflectiveAccessToJdk Adds --add-opens flags for Java 16+ reflective access.

checkTestJvmConstraints runs with check and rejects unsatisfiable constraints. The JDK name lists apply when tests run with -PtestJvm.

./gradlew :dd-java-agent:instrumentation:<instrumentation>:checkTestJvmConstraints

Running tests with a specific JVM:

./gradlew allTests -PtestJvm=zulu11

JMH Benchmarks (dd-trace-java.jmh-conventions Plugin)

The convention uses the JVM selected by the dd-trace-java.test-jvm-constraints plugin via -PtestJvm as the JMH launcher. It also maps optional -Pjmh.* project properties to JMH parameters, allowing an individual run to be adjusted without editing benchmark annotations. Explicit settings in a module’s jmh {} block take precedence (i.e. the managed property won't apply).

The new properties enable to override the defaults, in other words

  • without jmh.forks or jmh.threads, forks and threads come from @Fork and @Threads; otherwise JMH defaults apply,
  • without jmh.includes, JMH runs all discovered benchmarks,
  • without jmh.profilers, no profiler is attached unless configured elsewhere.
plugins {
    id("dd-trace-java.jmh-conventions")
}

jmh {
    jmhVersion = libs.versions.jmh.get()
    duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE
    
    // jvm, fork, profilers are managed by the jmh convention plugin 
    threads = 10 // Threads is enforced and jmh.threads won't be applied
}
Property Effect
jmh.includes Comma-separated benchmark name patterns to run (a subset run)
jmh.profilers Comma-separated JMH profilers to attach (e.g. stack, gc)
jmh.forks Overrides the fork count for a spot-check run
jmh.threads Overrides the thread count for a spot-check run
./gradlew :dd-trace-core:jmh -Pjmh.includes=SpanCreationBenchmark -Pjmh.forks=1 -Pjmh.threads=1 -PtestJvm=21

Tips:

  • Keep the benchmark's fork and thread settings aligned with what it measures.
  • Use -Pjmh.profilers=gc when investigating allocations.

tracerJava Extension

Manages multi-version Java source sets, allowing a single project to compile code targeting different JVM versions.

// In build.gradle.kts
plugins {
    id("dd-trace-java.module.internal-library")
}

tracerJava {
    addSourceSetFor(JavaVersion.VERSION_11) {
        applyForTestSources = true   // Apply version constraints to tests, default
    }
}

This creates source sets for version-specific code:

src/
  main/
    java/        # Java 8 code (default)
    java11/      # Java 11 specific code
  test/
    java/        # Compiled as Java 11     

CI Slots and Parallelization

Distributes test execution across multiple CI jobs using hash-based project slotting.

# Run only projects assigned to slot 2 out of 4 parallel jobs
./gradlew test -Pslot=2/4

# Without slot parameter, all projects run
./gradlew test

Projects are assigned to slots based on a hash of their path, ensuring consistent distribution across builds.

Git Change Tracking

Optimizes CI by skipping tests for projects unaffected by git changes.

# Only run tests for projects with changes between master and HEAD
./gradlew baseTest -PgitBaseRef=master

# Specify both refs explicitly
./gradlew baseTest -PgitBaseRef=master -PgitNewRef=feature-branch

The system:

  • Detects changed files via git diff
  • Maps changes to affected projects
  • Skips tests for unchanged projects
  • Falls back to running all tests if "global effect" files change (e.g., gradle/, build.gradle)

Root Aggregate Tasks

The root build.gradle.kts defines aggregate tasks that orchestrate testing across subprojects with slot filtering and git change tracking.

Task Projects Included
baseTest / baseLatestDepTest / baseCheck All projects except smoke, instrumentation, profiling, debugger
instrumentationTest / instrumentationLatestDepTest / instrumentationCheck :dd-java-agent:instrumentation
smokeTest / smokeLatestDepTest / smokeCheck :dd-smoke-tests
profilingTest / profilingLatestDepTest / profilingCheck :dd-java-agent:agent-profiling
debuggerTest / debuggerLatestDepTest / debuggerCheck :dd-java-agent:agent-debugger

Combined usage:

# Run instrumentation tests in slot 1/4, only for changed projects, with coverage
./gradlew instrumentationTest -Pslot=1/4 -PgitBaseRef=main -PcheckCoverage

Note

These root tasks are defined using testAggregate() in build.gradle.kts. They combine CI slot filtering, git change tracking, and optional JaCoCo coverage into convenient entry points for CI pipelines.

Convention Plugins

Convention plugins are the recommended way to share build logic across projects. They encapsulate common configuration patterns and can be applied like any other plugin.

Tip

Convention plugins promote consistency across modules. Instead of copy-pasting configuration, define it once and apply it everywhere.

Plugin Boundaries

Plugins retain separate responsibilities even within buildSrc. Plugin IDs, documented extensions, properties, task paths and public Gradle APIs let them interact without depending on each other's implementation details. Shared code is useful when it represents knowledge that must change together across plugins.

See Gradle's plugin development guide.

Dependencies on Main Projects

Build tooling remains independent of production code. The tasks it registers can consume production artifacts through declared inputs. Convention plugins can also declare project dependencies for the projects they configure.

Concrete project paths usually belong in the consuming build, keeping reusable plugins independent of the repository layout. Exceptions have a documented reason.

buildSrc is compiled before the main build scripts are evaluated. Gradle schedules tasks for project dependencies as part of the main build.

Project Convention Plugins

Files ending in .gradle.kts placed in buildSrc/src/main/kotlin/ target Project and can configure tasks, dependencies, and extensions. The buildSrc/ directory is automatically included by Gradle before the main build.

New convention plugins belong in build-logic. During the script-plugin migration, conventions that need shared buildSrc classes still need to land in buildSrc; see Build Logic Location.

Important

Hard-coded project paths such as :dd-trace-api tie a plugin to the repository layout, in other words it creates coupling. They belong in the consuming build script unless the dependency is part of the convention; see Dependencies on Main Projects.

In this project, convention plugins use the dd-trace-java. prefix. For example, dd-trace-java.configure-tests.gradle.kts configures test tasks across all subprojects:

// buildSrc/src/main/kotlin/dd-trace-java.configure-tests.gradle.kts (excerpt)

// Use lazy providers to avoid evaluating the property until it is needed
val skipTestsProvider = rootProject.providers.gradleProperty("skipTests")
val skipForkedTestsProvider = rootProject.providers.gradleProperty("skipForkedTests")

// Go through the Test tasks and configure them
tasks.withType<Test>().configureEach {
  // Disable all tests if skipTests property was specified
  onlyIf("skipTests are undefined or false") { !skipTestsProvider.isPresent }

  // Set test timeout for 20 minutes
  timeout = Duration.of(20, ChronoUnit.MINUTES))
  
  // ...
}

Apply it in any subproject:

// some/build.gradle.kts
plugins {
    id("dd-trace-java.configure-tests")
}

Module Convention Plugins

Module convention plugins are the preferred entry points for module build files. They are a stable facade over shared project setup: consumers use a plugin ID, while the implementation can keep delegating to existing script plugins until that build logic is migrated.

Use the most specific module plugin instead of applying gradle/java.gradle directly:

Module kind Plugin ID
Product subsystem modules dd-trace-java.module.product-subsystem
Annotation processors dd-trace-java.module.annotation-processor
Bootstrap components dd-trace-java.module.bootstrap-component
Published APIs dd-trace-java.module.distributable.api
Instrumentation modules dd-trace-java.module.instrumentation
Internal API modules dd-trace-java.module.internal-api
Product libraries dd-trace-java.module.product-library
Internal implementation libraries dd-trace-java.module.internal-library
Platform components dd-trace-java.module.platform-component
Smoke-test modules dd-trace-java.module.smoke-test
Testing support modules dd-trace-java.module.testing-support

Tip

  • Use internal-api for internal API surfaces such as product *-api modules or remote-config-api.
  • Use internal-library for internal implementation modules such as product *-lib modules, :communication, :telemetry, :utils:*, and similar shared libraries.
  • Use platform-component only for platform modules under :components; those modules are kept separate because they can grow stricter dependency and testing constraints than general internal libraries.

For example:

plugins {
  id 'dd-trace-java.module.instrumentation'
}

Avoid adding new direct uses of:

apply from: "$rootDir/gradle/java.gradle"

spotlessCheck enforces this for Gradle build scripts, with temporary exceptions only for modules that still need to be migrated separately.

Other convention plugins in this project include:

  • dd-trace-java.gradle-debug - Debugging utilities for build diagnostics
  • dd-trace-java.dependency-locking - Dependency locking configuration
  • dd-trace-java.test-jvm-constraints - JVM constraints for test execution

Settings Convention Plugins

Files ending in .settings.gradle.kts target Settings and can configure repository declarations, plugin management, and build structure.

// buildSrc/src/main/kotlin/my-settings-convention.settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}

Script Plugins

Warning

❌ Don't create new ones. Script plugins are deprecated. Gradle 9 documentation no longer mentions them as a recommended practice. They bring several issues.

Script plugins are standalone .gradle or .gradle.kts files that can be applied using the apply from: syntax. In this project, they are located in the gradle/ directory.

// Applying a script plugin
apply(from = "$rootDir/gradle/some-script.gradle")

As warned, don't write new ones, use convention plugins instead !

  • No type safety: When written in Groovy DSL, you lose IDE support and compile-time checking
  • Mixed DSL confusion: Projects often end up with a mix of Groovy and Kotlin scripts
  • Poor discoverability: Applied scripts are harder to trace than plugin IDs
  • No caching: Script plugins are re-evaluated on every build

There's an ongoing effort to migrate all of them to convention plugins for better maintainability and performance. For module build files, use a dd-trace-java.module.* plugin instead of applying gradle/java.gradle directly.

Gradle Lazy API

Gradle configures participating projects before executing tasks, unless it can reuse the configuration cache. Configuration work affects even short commands such as help. With roughly 630 projects and 33,000 tasks in dd-trace-java, unnecessary task creation and configuration can add substantial overhead.

Gradle's lazy APIs defer task creation, task configuration and value calculation until needed.

Why Lazy Configuration Matters

The following methods register, named and configureEach avoid creating and configuring unused tasks. Task configuration still belongs to the configuration phase. The providers defer value calculation until they are queried, which can happen during configuration or execution. Providers backed by task outputs also carry producer dependencies.

Eager vs Lazy API Comparison

Eager (Don't ❌) Lazy (Prefer ✅) Notes
configurations.getByName("x") configurations.named("x") Defers realization of the configuration object, not dependency resolution
tasks.getByName("x") tasks.named("x") Avoids creating and configuring the task during lookup
tasks.findByName("x") tasks.named("x") findByName returns null if absent; named throws instead
tasks.findByPath(":x") tasks.named("x") in the owning project findByPath returns null if absent; named takes a local name and throws if absent
tasks.create("x") tasks.register("x") Registers the task without creating or configuring it
property.set(computeValue()) property.set(provider { computeValue() }) Defers the computation; wrapping an already computed value adds no laziness
collection.all { } collection.configureEach { } Runs configuration actions only for realized domain objects
collection.forEach { } collection.configureEach { } Applies to configuration actions, rather than collection transformations
file(path).exists() Declared task inputs/outputs Tracks file dependencies; configuration-time existence checks may still be necessary
configuration.files inputs.files(configuration) Passes the file collection through and preserves producer dependencies
exec { }.exitValue providers.exec { ... } exec runs immediately; providers.exec defers execution until its output is queried
ProcessBuilder(...).start() providers.exec { ... } Exposes process output lazily; see the example under Using Providers

Important

Iterating a task or configuration container can realize its elements. Any function that iterates over a Gradle collection (forEach, map, filter, all, any, find, first, etc.) will eagerly realize all elements, defeating any other lazy API usage.

Instead prefer,

  • configureEach registers actions for realized elements;
  • withType creates a filtered live collection.
  • Provider transformations such as map and flatMap remain lazy until queried.

Warning

Groovy DSL pitfall: Dynamic task and configuration blocks (shorthand syntax theTaskName { }, or theConfigurationName { }) look up their target immediately (calling under the hood getByName("theTaskName"), or getByName("theConfigurationName")), realizing the object.

Note that just looking up a configuration by name does not resolve its dependencies.

// ❌ Eager - realizes the task immediately
compileLatestDepJava {
    options.encoding = 'UTF-8'
}

// ✅ Lazy - configures only when needed
tasks.named('compileLatestDepJava') {
    options.encoding = 'UTF-8'
}
// ❌ Eager - realizes the configuration object
configurations {
    runtimeClasspath {
        exclude group: 'org.slf4j'
    }
}

// ✅ Lazy - configures only when needed
configurations.named('runtimeClasspath') {
    exclude group: 'org.slf4j'
}

Task Registration

// ❌ Eager - task is created immediately
tasks.create("processData") {
    // configuration runs now, even if task is never executed
}

// ✅ Lazy - task is created only when needed
tasks.register("processData") {
    // Task configuration normally runs when this task is needed for the execution graph or eager lookup.
}

Configuration actions may run immediately, later or never. Mutating another task from one of these actions makes the result depend on realization order. Dependencies therefore belong in the consuming task's own configuration action.

Configuration Access

getByName realizes the configuration object. Its dependencies remain unresolved until Gradle needs their files.

// ❌ Eager - realizes the configuration object
val runtimeClasspath = configurations.getByName("runtimeClasspath")

// ✅ Lazy - returns a provider
val runtimeClasspath = configurations.named("runtimeClasspath")

Queries such as .files, .singleFile or .resolve() trigger dependency resolution. Task inputs can accept a configuration as a file collection without resolving it in build logic.

Walking Collections

// ❌ Eager - forces all tasks to be created
tasks.all {
    if (this is JavaCompile) {
        options.encoding = "UTF-8"
    }
}

// ❌ Eager - realizes all JavaCompile tasks
tasks.withType<JavaCompile> {
    options.encoding = "UTF-8"
}

// ✅ Lazy - configures each task as it's realized
tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
}

Using Providers

A provider represents a value that Gradle can query when needed. Properties and file collections that accept providers retain this connection without reading the value immediately. map transforms a value; flatMap connects to another provider, such as a task's output property.

With the Java plugin applied, these examples show eager and lazy access to its JAR:

// ❌ Eager - reads the archive path while configuring this task
tasks.register<Copy>("stageJarEager") {
    from(tasks.named<Jar>("jar").get().archiveFile.get())
    into(layout.buildDirectory.dir("staged-eager"))
}

// ✅ Lazy - preserves the output provider and its dependency on the jar task
tasks.register<Copy>("stageJar") {
    from(tasks.named<Jar>("jar").flatMap { it.archiveFile })
    into(layout.buildDirectory.dir("staged"))
}

The lazy version also carries the producer dependency, so Gradle runs jar before stageJar. The eager version passes only a file path and loses that dependency information.

Tip

Kotlin DSL supports = for Gradle lazy properties, for example archiveFileName = "app.jar". This has the same effect as archiveFileName.set("app.jar"); both forms also accept providers.

Provider support depends on the receiving API. Some APIs with Any or Object parameters convert a provider to a string instead of reading its value. JVM arguments are one example; see Lazy JVM Arguments and System Properties.

Command output can also be exposed as a provider:

val gitVersion = providers.exec {
    commandLine("git", "--version")
}.standardOutput.asText.map { it.trim() }

The command runs when its output is queried. A configuration-time query makes that output a configuration-cache input.

Tip

Existing afterEvaluate blocks can often be refactored away using providers, lazy task configuration and plugin callbacks.

Custom Value Sources

When a provider is queried during configuration, Gradle tracks the external data it reads to decide whether the configuration cache can be reused. A custom ValueSource lets Gradle track the computed result instead. This is useful when only part of the data affects configuration.

For example, reading the version entry from a release.properties file that also contains other release metadata:

import java.util.Properties

val releaseFile = layout.projectDirectory.file("release.properties")

// ❌ Any file content change invalidates the configuration cache
val releaseVersionProvider = providers.fileContents(releaseFile).asText.map {
    Properties().apply { load(it.reader()) }.getProperty("version")
}

// ✅ Only a change to the returned version invalidates the configuration cache
abstract class ReleaseVersionValueSource :
    ValueSource<String, ReleaseVersionValueSource.Parameters> {
    interface Parameters : ValueSourceParameters {
        val releaseFile: RegularFileProperty
    }

    override fun obtain(): String =
        parameters.releaseFile.get().asFile.reader().use {
            Properties().apply { load(it) }.getProperty("version")
        }
}

val releaseVersion = providers.of(ReleaseVersionValueSource::class) {
    parameters.releaseFile.set(releaseFile)
}

When queried during configuration, obtain() is rerun on subsequent builds to check its result. Changes to other entries leave the returned version unchanged and allow cache reuse. Built-in providers remain sufficient for simple environment, file and command reads.

A value source is also a good fit to run a CLI through ExecOperations:

import java.io.ByteArrayOutputStream

abstract class GitVersionValueSource @Inject constructor(
    private val execOperations: ExecOperations
) : ValueSource<String, ValueSourceParameters.None> {
    override fun obtain(): String {
        val output = ByteArrayOutputStream()
        execOperations.exec {
            commandLine("git", "--version")
            standardOutput = output
        }
        return output.toString().trim()
    }
}

val gitVersion = providers.of(GitVersionValueSource::class) {}

For a simple command, providers.exec is shorter. A custom source is useful when combining command output with other external data. The command runs again when Gradle checks cache reuse, so slow commands add overhead even when the cached configuration is reused.

Lazy JVM Arguments and System Properties

A common issue is passing arguments immediately by reading task outputs of other tasks or computing values during configuration.

Instead, a CommandLineArgumentProvider supplies JVM arguments from tracked task inputs. It is applicable to Test, JavaExec, Exec, and any others. For example, this can be used to provide the output of Jar tasks (in the below example fooJar and barJar) are needed to pass a system property and an environment var.

// ❌ Eager - reads producer task properties while configuring Test
tasks.withType<Test>().configureEach {
    val fooJarTask = tasks.named<Jar>("fooJar")
    val barJarTask = tasks.named<Jar>("barJar")
    dependsOn(fooJarTask, barJarTask)

    systemProperty("smoketest.foo.path", fooJarTask.get().archiveFile.get().asFile.absolutePath)
    environment("BAR_PATH", barJarTask.get().archiveFile.get().asFile.absolutePath)
}

abstract class SmokeTestJvmArguments : CommandLineArgumentProvider {
    @get:InputFile
    @get:PathSensitive(PathSensitivity.NONE)
    abstract val fooJar: RegularFileProperty

    override fun asArguments(): Iterable<String> =
        listOf("-Dsmoketest.foo.path=${fooJar.get().asFile.absolutePath}")
}

// ✅ Lazy - tracks the artifacts and reads their paths when needed
tasks.withType<Test>().configureEach {
    val fooJar = tasks.named<Jar>("fooJar").flatMap { it.archiveFile }
    val barJar = tasks.named<Jar>("barJar").flatMap { it.archiveFile }
    val arguments = objects.newInstance<SmokeTestJvmArguments>()
    arguments.fooJar = fooJar
    jvmArgumentProviders.add(arguments)
    
    // Workaround: environment() currently do not accept providers
    inputs.file(barJar).withPropertyName("barJar").withPathSensitivity(PathSensitivity.NONE)
    doFirst {
        environment("BAR_PATH", barJar.get().asFile.absolutePath)
    }
}

@InputFile on SmokeTestJvmArguments.fooJar makes the JAR's fingerprint of the test task's up-to-date checks. Setting that property from the fooJar task's output also schedules fooJar before the tests. inputs.file(barJar) provides the same tracking and dependency for barJar; doFirst sets BAR_PATH before the test process starts.

Caution

Deferred evaluation alone does not guarantee input tracking or configuration-cache compatibility. Argument providers and task actions follow the same configuration-cache requirements.

  • An incorrect input on an argument provider coud track path string without tracking the file's contents:

    // ❌ Tracks the path string, so changes to the JAR at that path are missed
    abstract class PathOnlyJvmArguments : CommandLineArgumentProvider {
        @get:Input
        abstract val fooJarPath: Property<String>
    
        override fun asArguments(): Iterable<String> =
            listOf("-Dsmoketest.foo.path=${fooJarPath.get()}")
    }

    The earlier SmokeTestJvmArguments properly uses @InputFile on a RegularFileProperty to track the JAR's contents.

  • This assignment is deferred, but does not track the JAR's contents or schedule its producer:

    // ❌ Deferred assignment without a declared file input
    tasks.withType<Test>().configureEach {
        val barJar = tasks.named<Jar>("barJar").flatMap { it.archiveFile }
        doFirst {
            environment("BAR_PATH", barJar.get().asFile.absolutePath)
        }
    }

    The inputs.file(barJar) declaration in the lazy example above provides both.

  • Capturing another task in an action also prevents configuration-cache storage:

    // ❌ Deferred lookup that retains the producing task
    tasks.withType<Test>().configureEach {
        val fooJarTask = tasks.named<Jar>("fooJar").get()
        dependsOn(fooJarTask)
        doFirst {
            systemProperty("smoketest.foo.path", fooJarTask.archiveFile.get().asFile.absolutePath)
        }
    }

    dependsOn orders the tasks, but the captured Jar task cannot be stored in the configuration cache. The lazy example reads the declared fooJar file property instead.

Replacing afterEvaluate

afterEvaluate runs after a project's build script, during configuration. Its callbacks run in registration order, which makes interactions between plugins fragile. Existing blocks can often be eliminated with provider wiring, lazy task configuration or pluginManager.withPlugin, depending on what they are waiting for.

Benefits of Lazy Configuration

  1. Faster configuration: Only necessary work is performed
  2. Automatic dependency tracking: Gradle knows which tasks produce values that others consume
  3. Configuration cache compatibility: Explicit inputs and compatible provider implementations support configuration cache reuse
  4. Correct ordering: Task dependencies are inferred from provider relationships

Gradle Daemon JVM

The Gradle Daemon is a long-lived background process that speeds up builds by avoiding JVM startup costs and caching project information. Configuring the Daemon JVM ensures consistent build behavior across machines.

Daemon JVM Criteria

You can specify criteria for the JVM that runs the Gradle Daemon. This is configured in gradle/gradle-daemon-jvm.properties:

# gradle/gradle-daemon-jvm.properties
toolchainVersion=21

When this file exists, Gradle will automatically provision a JVM matching the criteria using toolchain resolvers. This ensures all developers and CI systems use the same JVM version to run the build, regardless of their local JAVA_HOME.

Note

The Daemon JVM is separate from the toolchain used to compile your code. The Daemon JVM runs Gradle itself, while compilation toolchains (configured via java.toolchain) compile your source files.

Updating the Daemon JVM

To change the Daemon JVM version, use the built-in updateDaemonJvm task:

# Update to a specific JVM version
./gradlew updateDaemonJvm --jvm-version=21

This task updates the gradle/gradle-daemon-jvm.properties file with the new criteria. Commit this file to version control so the entire team uses the same Daemon JVM.

Caution

Not using this task will break the JDK auto-provisioning for the Gradle Daemon.

Troubleshooting

When Gradle builds fail or behave unexpectedly, several tools and techniques can help diagnose the problem.

For intermittent CI test failures, see reproducing timing-sensitive CI failures. That guide explains how to pass -Xint and -XX:ActiveProcessorCount to test JVMs, force fresh test execution, and distinguish Gradle worker limits from application CPU limits.

Tip

In general following the Gradle best practices is safe bet to avoid isses.

S3 Build Cache

Gradle's build cache reuses task outputs when their inputs match. S3 shares those outputs across CI jobs and pipelines. Cacheable tasks must declare all inputs that affect their outputs. Otherwise, Gradle may reuse stale outputs when an untracked input changes.

Pipeline Default prefix
Non-tag (including PR, nightly, and master) v1/<architecture>/<os>/
Release tag v1/release/<CI_PIPELINE_ID>/<architecture>/<os>/

S3_BUILD_CACHE_PREFIX overrides the entire prefix; Gradle appends the task cache key. Include the pipeline ID in release overrides to preserve isolation.

Forcing Fresh CI Task Execution

Set the GitLab CI variable SKIP_BUILD_CACHE=true when starting a pipeline, or include the case-sensitive [ci: SKIP_BUILD_CACHE] option in the commit message. Combine it with other CI options as needed, for example [ci: ARM64, SKIP_BUILD_CACHE]. For a fresh rerun, start a new pipeline for the same ref with this variable. This appends --no-build-cache --rerun-tasks to the shared Gradle arguments, bypassing local and remote task-output caches (including S3 when configured) and forcing selected tasks to execute even when their outputs are up to date. Existing task filters and skip conditions still apply.

The default is false. Dependency and plugin caches remain available.

The commit-message option is ignored in the publish and publish-release-artifacts stages. The explicit SKIP_BUILD_CACHE=true variable still applies there.

For the same behavior locally, pass the Gradle flags directly:

./gradlew :path:to:module:test --no-build-cache --rerun-tasks

Build Scans (Develocity)

This project uses Gradle Develocity for build scans. Build scans provide detailed insights into build performance, dependency resolution, and failures.

# Generate a build scan (requires accepting terms of use)
./gradlew build --scan

On CI, build scans are automatically published (unless SKIP_BUILDSCAN=true). The scan URL is printed at the end of the build output.

What build scans show:

  • Build timeline and task execution order
  • Dependency resolution details and conflicts
  • Test results with failure details
  • Configuration cache hits/misses
  • Build cache effectiveness

For performance analysis, focus on these sections:

Section What to look for
Performance → Build Total build time breakdown: configuration vs execution vs overhead
Performance → Configuration Slow scripts, plugin apply times, expensive configuration logic
Performance → Task execution Parallelism utilization, task wait times, serial bottlenecks
Timeline Visual task execution, look for long sequential chains
Timeline → Critical path Tasks that directly impact total build time—optimize these first
Build cache Cache hit rates, "not cacheable" tasks that could be
Dependency resolution Slow repositories, resolution time per configuration

Tip

The critical path shows tasks that directly determine build duration. Parallelizing or speeding up tasks not on the critical path won't reduce total build time.

Access it from the Timeline section, click the search icon 🔍 on top-left, you should see a On critical path toggle to activate focus on these tasks.

Now, inspect the outcome if this task, in particular

  • if the task is not UP-TO-DATE,
  • if the task is not cacheable, for reasons like overlapping outputs, caching has not been enabled, more information is needed to cache this task.

Diagnosing tasks that should be UP-TO-DATE but aren't:

  1. In build scans: Navigate to Timeline → click on the task → Inputs tab. Look for inputs that change unexpectedly between builds (timestamps, absolute paths, non-deterministic values). The Outcome section also shows the reason for execution (e.g., "Input property 'source' has changed").

  2. With --info flag: Gradle logs why each task executed (same info as build scans but locally):

    ./gradlew :my-project:compileJava --info
    # Look for: "Task ':my-project:compileJava' is not up-to-date because:"
    #   - "Input property 'source' file /path/to/File.java has changed."
    #   - "Output property 'destinationDirectory' file /path/to/classes has been removed."
  3. Common causes of unexpected re-execution:

    • Undeclared inputs (task reads files not tracked as inputs)
    • Non-reproducible inputs (timestamps, random values, absolute paths)
    • Outputs modified by another task or external process
    • Missing @PathSensitive annotation causing full path comparison instead of relative

Diagnostic Flags

Gradle provides several flags to increase output verbosity:

Flag Description
--info Adds informational log messages (recommended starting point)
--debug Very verbose output including internal Gradle operations
--stacktrace Prints stack traces for exceptions
--full-stacktrace Prints full stack traces (including internal frames)
--scan Generates a build scan with detailed diagnostics
--dry-run / -m Shows which tasks would run without executing them
--console=verbose Shows all task outcomes including UP-TO-DATE
# Diagnose a failing task with info logging
./gradlew :my-project:test --info

# See full exception details
./gradlew build --stacktrace

# Combine for thorough diagnosis
./gradlew build --info --stacktrace --scan

The gradle-debug Convention Plugin

This project includes a debug plugin that logs JDK information for all tasks. Enable it with -PddGradleDebug:

./gradlew build -PddGradleDebug

This writes JSON-formatted task/JDK mappings to build/datadog.gradle-debug.log:

{"task":":dd-trace-api:compileJava","jdk":"8"}
{"task":":dd-trace-api:test","jdk":"11"}

Use this to diagnose JDK-related issues, especially when tasks use unexpected Java versions.

Common Issues and Solutions

"Could not resolve dependency"

# Check dependency resolution details
./gradlew :my-project:dependencies --configuration runtimeClasspath

# Force refresh of dependencies
./gradlew build --refresh-dependencies

"Task ... uses this output of task ... without declaring an explicit or implicit dependency"

This indicates a missing task dependency. The producing task must be declared as a dependency:

tasks.named("consumingTask") {
    // Declare the task inputs. 
    inputs.files(producedPathProvider) // producedPathProvider is declared earlier in the build
    // This works as well but it requires the task be already registered.
    inputs.files(tasks.named("producingTask").map { it.outputs })

    // You should avoid that, but if the above doesn't work, track the task dependency explicitly
    dependsOn(tasks.named("producingTask"))
}

Out of memory errors

Increase Gradle daemon memory in gradle.properties:

org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError

Daemon seems stuck or slow

# Stop all daemons and start fresh
./gradlew --stop
./gradlew build

Configuration Cache Issues

The configuration cache speeds up builds by caching the task graph. However, some code patterns are incompatible.

Common configuration cache violations:

  1. Capturing Project at execution time:

    // ❌ Bad - captures Project reference
    tasks.register("myTask") {
        doLast {
            println(project.version)  // Not allowed!
        }
    }
    
    // ✅ Good - capture value at configuration time
    tasks.register("myTask") {
        val version = project.version
        doLast {
            println(version)
        }
    }
  2. Using Task.project in task actions:

    // ❌ Bad
    tasks.register("myTask") {
        doLast {
            copy {
                from(project.file("src"))  // Not allowed!
            }
        }
    }
    
    // ✅ Good - use task properties
    tasks.register<Copy>("myTask") {
        from("src")
    }

Diagnosing configuration cache problems:

# Run with configuration cache and see what fails
./gradlew build --configuration-cache

# Generate a detailed report
./gradlew build --configuration-cache --configuration-cache-problems=warn

The report shows exactly which code paths capture disallowed references.

Useful Diagnostic Tasks

# Show project structure
./gradlew projects

# List all tasks in a project
./gradlew :my-project:tasks --all

# Show applied plugins
./gradlew :my-project:buildEnvironment

# Show all resolvable configurations
./gradlew :my-project:resolvableConfigurations

# Show outgoing variants (what this project exposes)
./gradlew :my-project:outgoingVariants

# Validate build logic without running tasks
./gradlew help --scan