diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a583190fcb3..e8574c97991 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -68,6 +68,10 @@ variables: description: "Enable flaky tests" value: "false" + JAVA_PROFILER_REF: + description: "When non-empty, clone DataDog/java-profiler at this Git ref (branch or tag), build ddprof, and use it as ddprof.jar for Gradle jobs instead of the Maven dependency." + value: "paul.fournillon/wallclock_precheck" + # One pipeline injection package size ratchet OCI_PACKAGE_MAX_SIZE_BYTES: 40_000_000 LIB_INJECTION_IMAGE_MAX_SIZE_BYTES: 40_000_000 @@ -171,9 +175,21 @@ default: echo "Failed to find base ref for PR" >&2 fi +# When build_java_profiler_ddprof ran, its artifact is available at custom-ddprof/ddprof.jar. +# Append root project property expected by dd-java-agent/ddprof-lib/build.gradle. +.inject_custom_ddprof_jar: &inject_custom_ddprof_jar + - | + if [ -f "${CI_PROJECT_DIR}/custom-ddprof/ddprof.jar" ]; then + echo "ddprof.jar=${CI_PROJECT_DIR}/custom-ddprof/ddprof.jar" >> gradle.properties + echo "Using custom ddprof.jar from java-profiler build" + fi + .gradle_build: &gradle_build image: ${BUILDER_IMAGE_REPO}:${BUILDER_IMAGE_VERSION_PREFIX}base stage: build + needs: + - job: build_java_profiler_ddprof + optional: true variables: MAVEN_OPTS: "-Xms256M -Xmx1024M" GRADLE_WORKERS: 6 @@ -223,6 +239,7 @@ default: org.gradle.java.installations.auto-download=false org.gradle.java.installations.fromEnv=$JAVA_HOMES EOF + - *inject_custom_ddprof_jar - mkdir -p .gradle - export GRADLE_USER_HOME=$(pwd)/.gradle # replace maven central part by MAVEN_REPOSITORY_PROXY in .mvn/wrapper/maven-wrapper.properties @@ -298,8 +315,73 @@ dd-octo-sts-pre-release-check: max: 2 when: always +# Builds java-profiler from JAVA_PROFILER_REF and publishes custom-ddprof/ddprof.jar for downstream Gradle jobs. +# Uses :ddprof-lib:assembleReleaseJar (not assembleRelease, which is native-only). JDK 21+ for release + JDK 17+ for Gradle 9. +build_java_profiler_ddprof: + image: ${BUILDER_IMAGE_REPO}:${BUILDER_IMAGE_VERSION_PREFIX}base + stage: build + rules: + - if: '$JAVA_PROFILER_REF =~ /.+/' + when: on_success + variables: + FF_USE_FASTZIP: "true" + CACHE_COMPRESSION_LEVEL: "slowest" + KUBERNETES_CPU_REQUEST: 10 + KUBERNETES_MEMORY_REQUEST: 20Gi + KUBERNETES_MEMORY_LIMIT: 20Gi + before_script: + - | + # java-profiler uses Gradle 9.x; Gradle requires JVM 17+. Builder image default java is often JDK 8. + if [ -n "${JAVA_21_HOME:-}" ] && [ -x "${JAVA_21_HOME}/bin/java" ]; then + export JAVA_HOME="$JAVA_21_HOME" + elif [ -n "${JAVA_17_HOME:-}" ] && [ -x "${JAVA_17_HOME}/bin/java" ]; then + export JAVA_HOME="$JAVA_17_HOME" + else + shopt -s nullglob + for d in /usr/lib/jvm/java-21-* /usr/lib/jvm/temurin-21-* /usr/lib/jvm/java-17-*; do + if [ -x "${d}/bin/java" ]; then + export JAVA_HOME="$d" + break + fi + done + shopt -u nullglob + fi + if [ -z "${JAVA_HOME:-}" ] || ! [ -x "${JAVA_HOME}/bin/java" ]; then + echo "Could not find JDK 17+ for Gradle 9 (set JAVA_21_HOME or JAVA_17_HOME, or install JDK 21 under /usr/lib/jvm)." >&2 + ls -la /usr/lib/jvm 2>/dev/null || true + exit 1 + fi + export PATH="${JAVA_HOME}/bin:${PATH}" + java -version + script: + - | + set -euo pipefail + mkdir -p "${CI_PROJECT_DIR}/custom-ddprof" + SRCDIR="${CI_PROJECT_DIR}/java-profiler-src" + rm -rf "$SRCDIR" + git clone --depth 1 --branch "$JAVA_PROFILER_REF" https://github.com/DataDog/java-profiler.git "$SRCDIR" + cd "$SRCDIR" + chmod +x ./gradlew + ./gradlew --version + # assembleRelease is the native link/assemble task only; the packaged jar is assembleReleaseJar. + ./gradlew :ddprof-lib:assembleReleaseJar -Pskip-tests -Pskip-gtest + JAR=$(find ddprof-lib/build/libs -maxdepth 1 -type f \( -name 'ddprof-*.jar' \) ! -name '*-sources*' ! -name '*-javadoc*' | head -1) + if [ -z "$JAR" ] || [ ! -f "$JAR" ]; then + echo "No ddprof jar found under ddprof-lib/build/libs" >&2 + ls -la ddprof-lib/build/libs 2>/dev/null || ls -laR ddprof-lib/build 2>/dev/null || true + exit 1 + fi + cp "$JAR" "${CI_PROJECT_DIR}/custom-ddprof/ddprof.jar" + ls -la "${CI_PROJECT_DIR}/custom-ddprof/" + artifacts: + when: on_success + paths: + - custom-ddprof/ddprof.jar + build: needs: + - job: build_java_profiler_ddprof + optional: true - job: maven-central-pre-release-check optional: true - job: dd-octo-sts-pre-release-check @@ -410,7 +492,9 @@ publish-artifacts-to-s3: spotless: extends: .gradle_build stage: tests - needs: [] + needs: + - job: build_java_profiler_ddprof + optional: true variables: GRADLE_MEMORY_MAX: 6G script: @@ -420,7 +504,9 @@ spotless: check-instrumentation-naming: extends: .gradle_build stage: tests - needs: [ ] + needs: + - job: build_java_profiler_ddprof + optional: true script: - ./gradlew --version - ./gradlew checkInstrumentationNaming @@ -428,7 +514,9 @@ check-instrumentation-naming: config-inversion-linter: extends: .gradle_build stage: tests - needs: [] + needs: + - job: build_java_profiler_ddprof + optional: true script: - ./gradlew --version - ./gradlew checkConfigurations @@ -437,7 +525,10 @@ test_published_artifacts: extends: .gradle_build image: ${BUILDER_IMAGE_REPO}:${BUILDER_IMAGE_VERSION_PREFIX}7 # Needs Java7 for some tests stage: tests - needs: [ build ] + needs: + - job: build_java_profiler_ddprof + optional: true + - build variables: CACHE_TYPE: "lib" script: @@ -464,7 +555,10 @@ test_published_artifacts: .check_job: extends: .gradle_build - needs: [ build ] + needs: + - job: build_java_profiler_ddprof + optional: true + - build stage: tests variables: CACHE_TYPE: "lib" @@ -500,7 +594,9 @@ test_published_artifacts: check_build_src: extends: .check_job - needs: [] + needs: + - job: build_java_profiler_ddprof + optional: true variables: GRADLE_TARGET: ":buildSrc:build" @@ -535,7 +631,10 @@ check_debugger: muzzle: extends: .gradle_build - needs: [ build_tests ] + needs: + - job: build_java_profiler_ddprof + optional: true + - build_tests stage: tests parallel: matrix: @@ -567,7 +666,10 @@ muzzle: muzzle-dep-report: extends: .gradle_build - needs: [ build_tests ] + needs: + - job: build_java_profiler_ddprof + optional: true + - build_tests stage: tests variables: CACHE_TYPE: "inst" @@ -604,7 +706,10 @@ muzzle-dep-report: extends: .gradle_build image: ${BUILDER_IMAGE_REPO}:${BUILDER_IMAGE_VERSION_PREFIX}$testJvm tags: [ "docker-in-docker:amd64" ] # use docker-in-docker runner for testcontainers - needs: [ build_tests ] + needs: + - job: build_java_profiler_ddprof + optional: true + - build_tests stage: tests variables: GRADLE_PARAMS: "-PskipFlakyTests" @@ -912,7 +1017,10 @@ deploy_to_di_backend:manual: deploy_to_maven_central: extends: .gradle_build stage: publish - needs: [ build ] + needs: + - job: build_java_profiler_ddprof + optional: true + - build variables: CACHE_TYPE: "lib" rules: @@ -940,7 +1048,10 @@ deploy_to_maven_central: deploy_snapshot_with_ddprof_snapshot: extends: .gradle_build stage: publish - needs: [ build ] + needs: + - job: build_java_profiler_ddprof + optional: true + - build variables: CACHE_TYPE: "lib" rules: diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/concurrent/TaskBlockHelper.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/concurrent/TaskBlockHelper.java new file mode 100644 index 00000000000..1696078761b --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/concurrent/TaskBlockHelper.java @@ -0,0 +1,64 @@ +package datadog.trace.bootstrap.instrumentation.java.concurrent; + +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; +import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; + +/** Helper for Java-level instrumentation that emits {@code datadog.TaskBlock} intervals. */ +public final class TaskBlockHelper { + static final long MIN_TASK_BLOCK_NANOS = 1_000_000L; + + private TaskBlockHelper() {} + + /** Captured state for a potential blocking interval. */ + public static final class State { + final ProfilingContextIntegration profiling; + final long startTicks; + final long startNanos; + final long spanId; + final long rootSpanId; + final long blocker; + + State( + ProfilingContextIntegration profiling, + long startTicks, + long startNanos, + long spanId, + long rootSpanId, + long blocker) { + this.profiling = profiling; + this.startTicks = startTicks; + this.startNanos = startNanos; + this.spanId = spanId; + this.rootSpanId = rootSpanId; + this.blocker = blocker; + } + } + + public static State capture(long blocker) { + return capture(blocker, AgentTracer.get().getProfilingContext(), AgentTracer.activeSpan()); + } + + static State capture(long blocker, ProfilingContextIntegration profiling, AgentSpan span) { + if (profiling == null || span == null || !(span.context() instanceof ProfilerContext)) { + return null; + } + ProfilerContext context = (ProfilerContext) span.context(); + return new State( + profiling, + profiling.getCurrentTicks(), + System.nanoTime(), + context.getSpanId(), + context.getRootSpanId(), + blocker); + } + + public static void finish(State state) { + if (state == null || System.nanoTime() - state.startNanos < MIN_TASK_BLOCK_NANOS) { + return; + } + state.profiling.recordTaskBlock( + state.startTicks, state.spanId, state.rootSpanId, state.blocker, 0L); + } +} diff --git a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/java/concurrent/TaskBlockHelperTest.groovy b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/java/concurrent/TaskBlockHelperTest.groovy new file mode 100644 index 00000000000..374a4a8d40d --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/java/concurrent/TaskBlockHelperTest.groovy @@ -0,0 +1,107 @@ +package datadog.trace.bootstrap.instrumentation.java.concurrent + +import datadog.trace.bootstrap.instrumentation.api.AgentSpan +import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext +import datadog.trace.bootstrap.instrumentation.api.ProfilerContext +import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration +import spock.lang.Specification + +class TaskBlockHelperTest extends Specification { + static final long SPAN_ID = 0xDEADBEEFL + static final long ROOT_SPAN_ID = 0xCAFEBABEL + static final long START_TICKS = 42_000_000L + static final long BLOCKER = 1234L + + def profiling = Mock(ProfilingContextIntegration) + def span = Mock(AgentSpan) + def ctx = Mock(ProfilerSpanContext) + def nonProfilerCtx = Mock(AgentSpanContext) + + def setup() { + span.context() >> ctx + ctx.getSpanId() >> SPAN_ID + ctx.getRootSpanId() >> ROOT_SPAN_ID + profiling.getCurrentTicks() >> START_TICKS + } + + def "capture returns null without profiling context"() { + expect: + TaskBlockHelper.capture(BLOCKER, null, span) == null + } + + def "capture returns null without active span"() { + expect: + TaskBlockHelper.capture(BLOCKER, profiling, null) == null + } + + def "capture returns null when span context is not profiler context"() { + setup: + def nonProfilerSpan = Mock(AgentSpan) + nonProfilerSpan.context() >> nonProfilerCtx + + expect: + TaskBlockHelper.capture(BLOCKER, profiling, nonProfilerSpan) == null + } + + def "capture records active span and entry timing"() { + setup: + long before = System.nanoTime() + + when: + def state = TaskBlockHelper.capture(BLOCKER, profiling, span) + + then: + state != null + state.profiling == profiling + state.startTicks == START_TICKS + state.startNanos >= before + state.startNanos <= System.nanoTime() + state.spanId == SPAN_ID + state.rootSpanId == ROOT_SPAN_ID + state.blocker == BLOCKER + } + + def "finish ignores null state"() { + when: + TaskBlockHelper.finish(null) + + then: + 0 * profiling._ + } + + def "finish ignores too-short intervals"() { + setup: + def state = new TaskBlockHelper.State( + profiling, + START_TICKS, + System.nanoTime() + 60_000_000_000L, + SPAN_ID, + ROOT_SPAN_ID, + BLOCKER) + + when: + TaskBlockHelper.finish(state) + + then: + 0 * profiling.recordTaskBlock(_, _, _, _, _) + } + + def "finish emits task block for eligible interval"() { + setup: + def state = new TaskBlockHelper.State( + profiling, + START_TICKS, + System.nanoTime() - 2 * TaskBlockHelper.MIN_TASK_BLOCK_NANOS, + SPAN_ID, + ROOT_SPAN_ID, + BLOCKER) + + when: + TaskBlockHelper.finish(state) + + then: + 1 * profiling.recordTaskBlock(START_TICKS, SPAN_ID, ROOT_SPAN_ID, BLOCKER, 0L) + } + + private interface ProfilerSpanContext extends AgentSpanContext, ProfilerContext {} +} diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java index b1e07b08c32..8abf48b769b 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java @@ -341,6 +341,17 @@ public void recordTraceRoot(long rootSpanId, String endpoint, String operation) } } + /** Monotonic tick count for TaskBlock and wall-clock off-CPU interval timing. */ + public long getCurrentTicks() { + return profiler.getCurrentTicks(); + } + + int encode(CharSequence constant) { + // java-profiler ContextSetter no longer exposes value encoding. + // Keep API contract by returning "not encoded" (0), which callers already handle. + return 0; + } + public int operationNameOffset() { return offsetOf(OPERATION); } @@ -454,4 +465,24 @@ void recordQueueTimeEvent( } } } + + void recordTaskBlockEvent( + long startTicks, long spanId, long rootSpanId, long blocker, long unblockingSpanId) { + if (profiler != null) { + long endTicks = profiler.getCurrentTicks(); + profiler.recordTaskBlock(startTicks, endTicks, spanId, rootSpanId, blocker, unblockingSpanId); + } + } + + void parkEnter(long spanId, long rootSpanId) { + if (profiler != null) { + profiler.parkEnter(spanId, rootSpanId); + } + } + + void parkExit(long blocker, long unblockingSpanId) { + if (profiler != null) { + profiler.parkExit(blocker, unblockingSpanId); + } + } } diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java index 00a0358d346..67af75a7afd 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java @@ -71,11 +71,53 @@ public void onDetach() { } } + @Override + public int encode(CharSequence constant) { + return DDPROF.encode(constant); + } + + @Override + public int encodeOperationName(CharSequence constant) { + if (SPAN_NAME_INDEX >= 0) { + return DDPROF.encode(constant); + } + return 0; + } + + @Override + public int encodeResourceName(CharSequence constant) { + if (RESOURCE_NAME_INDEX >= 0) { + return DDPROF.encode(constant); + } + return 0; + } + @Override public String name() { return "ddprof"; } + @Override + public long getCurrentTicks() { + return DDPROF.getCurrentTicks(); + } + + @Override + public void recordTaskBlock( + long startTicks, long spanId, long rootSpanId, long blocker, long unblockingSpanId) { + DDPROF.recordTaskBlockEvent(startTicks, spanId, rootSpanId, blocker, unblockingSpanId); + } + + @Override + public void parkEnter(long spanId, long rootSpanId) { + DDPROF.parkEnter(spanId, rootSpanId); + } + + @Override + public void parkExit(long blocker, long unblockingSpanId) { + DDPROF.parkExit(blocker, unblockingSpanId); + } + public void clearContext() { DDPROF.clearSpanContext(); DDPROF.clearContextValue(SPAN_NAME_INDEX); diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java index 55d39ba52a0..80754c7937f 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java @@ -20,6 +20,7 @@ import java.util.HashSet; import java.util.Properties; import java.util.UUID; +import java.util.concurrent.locks.LockSupport; import java.util.stream.IntStream; import java.util.stream.Stream; import org.junit.jupiter.api.Assumptions; @@ -29,6 +30,8 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.ItemFilters; import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -74,6 +77,47 @@ void test() throws Exception { } } + @Test + void testTaskBlockBridgeMethodsEmitTaskBlockEvents() throws Exception { + assertDoesNotThrow( + () -> DdprofLibraryLoader.jvmAccess().getReasonNotLoaded(), "Profiler not available"); + DatadogProfiler profiler = DatadogProfiler.newInstance(ConfigProvider.getInstance()); + if (profiler.isActive()) { + log.warn("Datadog profiler is already running. Skipping task-block integration test."); + return; + } + + OngoingRecording recording = profiler.start(); + if (recording == null) { + log.warn("Datadog Profiler is not available. Skipping task-block integration test."); + return; + } + + try { + // Direct bridge path (recordTaskBlock -> JavaProfiler.recordTaskBlock0) + long startTicks = profiler.getCurrentTicks(); + LockSupport.parkNanos(3_000_000L); // > 1ms native threshold + profiler.recordTaskBlockEvent(startTicks, 101L, 202L, 303L, 404L); + + // Park path (parkEnter/parkExit -> JavaProfiler.parkEnter0/parkExit0) + profiler.parkEnter(505L, 606L); + LockSupport.parkNanos(3_000_000L); // > 1ms native threshold + profiler.parkExit(707L, 808L); + + RecordingData data = profiler.stop(recording); + assertNotNull(data); + IItemCollection events = JfrLoaderToolkit.loadEvents(data.getStream()); + long taskBlockCount = + events.apply(ItemFilters.type("datadog.TaskBlock")).stream() + .mapToLong(IItemIterable::getItemCount) + .sum(); + + assertTrue(taskBlockCount > 0, "Expected datadog.TaskBlock events from bridge methods"); + } finally { + recording.stop(); + } + } + @ParameterizedTest @MethodSource("profilingModes") void testStartCmd(boolean cpu, boolean wall, boolean alloc, boolean memleak) throws Exception { diff --git a/dd-java-agent/instrumentation/datadog/profiling/lock-support/build.gradle b/dd-java-agent/instrumentation/datadog/profiling/lock-support/build.gradle new file mode 100644 index 00000000000..f2f91cdc7fd --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/profiling/lock-support/build.gradle @@ -0,0 +1,12 @@ +apply from: "$rootDir/gradle/java.gradle" + +muzzle { + pass { + coreJdk() + } +} + +dependencies { + testImplementation libs.bundles.junit5 + testImplementation libs.bundles.mockito +} diff --git a/dd-java-agent/instrumentation/datadog/profiling/lock-support/src/main/java/datadog/trace/instrumentation/locksupport/LockSupportProfilingInstrumentation.java b/dd-java-agent/instrumentation/datadog/profiling/lock-support/src/main/java/datadog/trace/instrumentation/locksupport/LockSupportProfilingInstrumentation.java new file mode 100644 index 00000000000..a892954ce62 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/profiling/lock-support/src/main/java/datadog/trace/instrumentation/locksupport/LockSupportProfilingInstrumentation.java @@ -0,0 +1,157 @@ +package datadog.trace.instrumentation.locksupport; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.isStatic; +import static net.bytebuddy.matcher.ElementMatchers.nameStartsWith; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; +import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; +import java.util.concurrent.ConcurrentHashMap; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.matcher.ElementMatchers; + +/** + * Instruments {@link java.util.concurrent.locks.LockSupport#park} variants as the Java entry point + * for native parked-state tracking. The native profiler uses this state to suppress wall-clock + * signals while the thread is parked and, when the interval belongs to an active span, to emit a + * replacement {@code datadog.TaskBlock} event on {@code parkExit}. + * + *
Also instruments {@link java.util.concurrent.locks.LockSupport#unpark} to capture the span ID + * of the unblocking thread, which is then recorded in the native TaskBlock event. + * + *
{@code parkEnter} runs even without an active span (span id 0) so the native wall-clock
+ * precheck can suppress {@code SIGVTALRM} for the whole park interval. TaskBlock JFR emission is
+ * gated by the profiler on duration and span context.
+ */
+@AutoService(InstrumenterModule.class)
+public class LockSupportProfilingInstrumentation extends InstrumenterModule.Profiling
+ implements Instrumenter.ForKnownTypes, Instrumenter.HasMethodAdvice {
+
+ public LockSupportProfilingInstrumentation() {
+ super("lock-support");
+ }
+
+ @Override
+ public String[] knownMatchingTypes() {
+ return new String[] {"java.util.concurrent.locks.LockSupport"};
+ }
+
+ @Override
+ public String[] muzzleIgnoredClassNames() {
+ // Static helpers on the advice class produce intra-class references that core-JDK muzzle
+ // cannot resolve against an empty application classpath.
+ return new String[] {
+ getClass().getName() + "$ParkAdvice",
+ getClass().getName() + "$State",
+ getClass().getName() + "$ParkState"
+ };
+ }
+
+ @Override
+ public void methodAdvice(MethodTransformer transformer) {
+ transformer.applyAdvice(
+ isMethod()
+ .and(isStatic())
+ .and(nameStartsWith("park"))
+ .and(isDeclaredBy(named("java.util.concurrent.locks.LockSupport"))),
+ getClass().getName() + "$ParkAdvice");
+ transformer.applyAdvice(
+ isMethod()
+ .and(isStatic())
+ .and(ElementMatchers.named("unpark"))
+ .and(isDeclaredBy(named("java.util.concurrent.locks.LockSupport"))),
+ getClass().getName() + "$UnparkAdvice");
+ }
+
+ /** Holds shared state accessible from both {@link ParkAdvice} and {@link UnparkAdvice}. */
+ public static final class State {
+ /** Maps target thread to the span ID of the thread that called {@code unpark()} on it. */
+ public static final ConcurrentHashMap These tests exercise the {@link State} map directly, verifying the mechanism used to
+ * communicate the unblocking span ID from {@code UnparkAdvice} to {@code ParkAdvice}.
+ */
+class LockSupportProfilingInstrumentationTest {
+
+ private static final long SPAN_ID = 1234L;
+ private static final long ROOT_SPAN_ID = 5678L;
+
+ private interface ProfilerSpanContext extends AgentSpanContext, ProfilerContext {}
+
+ @BeforeEach
+ void clearState() {
+ State.UNPARKING_SPAN.clear();
+ }
+
+ @AfterEach
+ void cleanupState() {
+ State.UNPARKING_SPAN.clear();
+ }
+
+ // -------------------------------------------------------------------------
+ // State map — basic contract
+ // -------------------------------------------------------------------------
+
+ @Test
+ void state_put_and_remove() {
+ Thread t = Thread.currentThread();
+ long spanId = 12345L;
+
+ State.UNPARKING_SPAN.put(t, spanId);
+ Long retrieved = State.UNPARKING_SPAN.remove(t);
+
+ assertNotNull(retrieved);
+ assertEquals(spanId, (long) retrieved);
+ // After removal the entry should be gone
+ assertNull(State.UNPARKING_SPAN.get(t));
+ }
+
+ @Test
+ void state_remove_returns_null_when_absent() {
+ Thread t = new Thread(() -> {});
+ assertNull(State.UNPARKING_SPAN.remove(t));
+ }
+
+ @Test
+ void state_is_initially_empty() {
+ assertTrue(State.UNPARKING_SPAN.isEmpty());
+ }
+
+ // -------------------------------------------------------------------------
+ // Multithreaded: unpark thread populates map, parked thread reads it
+ // -------------------------------------------------------------------------
+
+ /**
+ * Simulates the UnparkAdvice → ParkAdvice handoff:
+ *
+ * If {@code unpark(t)} is called (inserting an entry into {@code UNPARKING_SPAN}) and thread
+ * {@code t} then parks without an active span ({@code state == null}), the entry must still be
+ * drained. Without the fix, it would linger and be incorrectly attributed to the next {@code
+ * TaskBlock} emitted on that thread.
+ */
+ @Test
+ void stale_entry_is_drained_when_park_fires_without_active_span() {
+ Thread t = Thread.currentThread();
+ State.UNPARKING_SPAN.put(t, 99L);
+
+ // Simulate park() returning with no active span (state == null)
+ LockSupportProfilingInstrumentation.ParkAdvice.after(null);
+
+ assertNull(
+ State.UNPARKING_SPAN.get(t),
+ "Stale UNPARKING_SPAN entry must be drained even when state is null");
+ }
+
+ /**
+ * If multiple unpark calls race for the same parked thread, the latest span ID should be consumed
+ * and the entry must still be drained exactly once by ParkAdvice.after().
+ */
+ @Test
+ void latest_unparking_span_wins_and_entry_is_drained() {
+ Thread t = Thread.currentThread();
+ State.UNPARKING_SPAN.put(t, 101L);
+ State.UNPARKING_SPAN.put(t, 202L);
+
+ Long consumed = State.UNPARKING_SPAN.remove(t);
+ assertNotNull(consumed);
+ assertEquals(202L, consumed.longValue());
+ assertNull(State.UNPARKING_SPAN.get(t), "Entry must be removed after consumption");
+ }
+}
diff --git a/dd-java-agent/instrumentation/datadog/profiling/object-wait/build.gradle b/dd-java-agent/instrumentation/datadog/profiling/object-wait/build.gradle
new file mode 100644
index 00000000000..d61e8bd6155
--- /dev/null
+++ b/dd-java-agent/instrumentation/datadog/profiling/object-wait/build.gradle
@@ -0,0 +1,16 @@
+apply from: "$rootDir/gradle/java.gradle"
+
+testJvmConstraints {
+ minJavaVersion = JavaVersion.VERSION_21
+}
+
+muzzle {
+ pass {
+ coreJdk('21')
+ }
+}
+
+dependencies {
+ testImplementation libs.bundles.junit5
+ testImplementation libs.bundles.mockito
+}
diff --git a/dd-java-agent/instrumentation/datadog/profiling/object-wait/src/main/java/datadog/trace/instrumentation/objectwait/ObjectWaitProfilingInstrumentation.java b/dd-java-agent/instrumentation/datadog/profiling/object-wait/src/main/java/datadog/trace/instrumentation/objectwait/ObjectWaitProfilingInstrumentation.java
new file mode 100644
index 00000000000..615008857d9
--- /dev/null
+++ b/dd-java-agent/instrumentation/datadog/profiling/object-wait/src/main/java/datadog/trace/instrumentation/objectwait/ObjectWaitProfilingInstrumentation.java
@@ -0,0 +1,86 @@
+package datadog.trace.instrumentation.objectwait;
+
+import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy;
+import static net.bytebuddy.matcher.ElementMatchers.isMethod;
+import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+
+import com.google.auto.service.AutoService;
+import datadog.environment.JavaVirtualMachine;
+import datadog.trace.agent.tooling.Instrumenter;
+import datadog.trace.agent.tooling.InstrumenterModule;
+import datadog.trace.bootstrap.instrumentation.java.concurrent.TaskBlockHelper;
+import net.bytebuddy.asm.Advice;
+
+/**
+ * Instruments {@link Object#wait(long)} in JDK 21+ to emit {@code datadog.TaskBlock} JFR events.
+ *
+ * In JDK 21+, {@code wait(long)} is a pure-Java wrapper around the native {@code wait0(long)},
+ * so ByteBuddy can add advice to it. In JDK 8-20 the method is declared {@code native} and is not
+ * instrumented by this class (Approach 1 osThreadState precheck already suppresses SIGVTALRM for
+ * threads in OBJECT_WAIT state on all JDK versions).
+ *
+ * Only {@code wait(long)} is instrumented: {@code wait()} delegates to {@code wait(0L)} and
+ * {@code wait(long, int)} delegates to {@code wait(long)}, so all wait variants are covered.
+ *
+ * {@code unblockingSpanId} is always 0 because {@code notify()} and {@code notifyAll()} remain
+ * {@code native} in JDK 21+ and the notifying thread cannot be identified via BCI.
+ */
+@AutoService(InstrumenterModule.class)
+public class ObjectWaitProfilingInstrumentation extends InstrumenterModule.Profiling
+ implements Instrumenter.ForBootstrap, Instrumenter.ForKnownTypes, Instrumenter.HasMethodAdvice {
+
+ public ObjectWaitProfilingInstrumentation() {
+ super("object-wait");
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return JavaVirtualMachine.isJavaVersionAtLeast(21) && super.isEnabled();
+ }
+
+ @Override
+ public String[] knownMatchingTypes() {
+ return new String[] {"java.lang.Object"};
+ }
+
+ @Override
+ public String[] muzzleIgnoredClassNames() {
+ // Static helpers on the advice class produce intra-class references that core-JDK muzzle
+ // cannot resolve against an empty application classpath.
+ return new String[] {getClass().getName() + "$WaitAdvice"};
+ }
+
+ @Override
+ public void methodAdvice(MethodTransformer transformer) {
+ transformer.applyAdvice(
+ isMethod()
+ .and(named("wait"))
+ .and(takesArguments(1))
+ .and(takesArgument(0, long.class))
+ .and(isDeclaredBy(named("java.lang.Object"))),
+ getClass().getName() + "$WaitAdvice");
+ }
+
+ public static final class WaitAdvice {
+
+ @Advice.OnMethodEnter(suppress = Throwable.class)
+ public static TaskBlockHelper.State before(@Advice.This Object monitor) {
+ return captureState(monitor);
+ }
+
+ @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
+ public static void after(@Advice.Enter TaskBlockHelper.State state) {
+ finish(state);
+ }
+
+ static TaskBlockHelper.State captureState(Object monitor) {
+ return TaskBlockHelper.capture(System.identityHashCode(monitor));
+ }
+
+ static void finish(TaskBlockHelper.State state) {
+ TaskBlockHelper.finish(state);
+ }
+ }
+}
diff --git a/dd-java-agent/instrumentation/datadog/profiling/object-wait/src/test/java/datadog/trace/instrumentation/objectwait/ObjectWaitProfilingInstrumentationTest.java b/dd-java-agent/instrumentation/datadog/profiling/object-wait/src/test/java/datadog/trace/instrumentation/objectwait/ObjectWaitProfilingInstrumentationTest.java
new file mode 100644
index 00000000000..b556e8bc9e9
--- /dev/null
+++ b/dd-java-agent/instrumentation/datadog/profiling/object-wait/src/test/java/datadog/trace/instrumentation/objectwait/ObjectWaitProfilingInstrumentationTest.java
@@ -0,0 +1,63 @@
+package datadog.trace.instrumentation.objectwait;
+
+import static org.mockito.Mockito.verify;
+
+import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration;
+import datadog.trace.bootstrap.instrumentation.java.concurrent.TaskBlockHelper;
+import datadog.trace.instrumentation.objectwait.ObjectWaitProfilingInstrumentation.WaitAdvice;
+import java.lang.reflect.Constructor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * Unit tests for {@link ObjectWaitProfilingInstrumentation}.
+ *
+ * Detailed interval eligibility is covered by {@code TaskBlockHelperTest}; this class verifies
+ * that Object.wait advice delegates to the shared Java-level TaskBlock helper.
+ */
+@ExtendWith(MockitoExtension.class)
+class ObjectWaitProfilingInstrumentationTest {
+
+ private static final long SPAN_ID = 0xDEADBEEFL;
+ private static final long ROOT_SPAN_ID = 0xCAFEBABEL;
+ private static final long START_TICKS = 42_000_000L;
+ private static final long BLOCKER = 1234L;
+
+ @Mock private ProfilingContextIntegration profiling;
+
+ @Test
+ void after_nullState_doesNotThrow() {
+ WaitAdvice.after(null);
+ }
+
+ @Test
+ void after_eligibleState_emitsTaskBlockWithZeroUnblockingSpanId() throws Exception {
+ TaskBlockHelper.State state = newState(System.nanoTime() - 2 * taskBlockThresholdNanos());
+
+ WaitAdvice.after(state);
+
+ verify(profiling).recordTaskBlock(START_TICKS, SPAN_ID, ROOT_SPAN_ID, BLOCKER, 0L);
+ }
+
+ private TaskBlockHelper.State newState(long startNanos) throws Exception {
+ Constructor
+ *
+ */
+ @Test
+ void unparking_spanId_is_visible_to_parked_thread() throws InterruptedException {
+ long unparkingSpanId = 99887766L;
+
+ CountDownLatch ready = new CountDownLatch(1);
+ CountDownLatch go = new CountDownLatch(1);
+ AtomicLong capturedSpanId = new AtomicLong(-1L);
+ AtomicReference